OpenSearchのderivedフィールドタイプについて

Posted by johtani on Wednesday, July 24, 2024

目次

Derivedフィールドタイプとは、OpenSearch 2.15から導入された新しいフィールドタイプです。

以下の記事は、2.15.0のドキュメントおよびOpenSearchを利用して確認した内容になります。

どういうもの?

既存のフィールド(_sourceのフィールドもしくはdoc valuesから)新しいフィールドを「動的」に作成できます。 derivedフィールドタイプを利用できるできないの設定が用意されており、不要であればオフにすることも可能です。 デフォルトでは利用可能な状態になっています。

スクリプトを定義して、元のフィールドから特定の箇所の文字列などを抜き出し、特定の型(dateとかlongとか)として扱えるようにします。 例えば、date型の場合はformatを指定することで、日付文字列としてパースしたりといった具合です。

なにができるの?

通常、OpenSearchの検索条件に利用するフィールドは指定されたフィールド型でインデックスに登録され、検索用のデータ構造が生成されている必要があります。 これは、データ登録を行う時点でどんなデータがあり、どういった検索をしたいか?というのを決めておく必要があるということです(Schema on Write)。 例えば、ログなどを保存しておいて解析したい場合に、あらかじめ、ログ文字列から必要な情報(日付、サーバ名、IPアドレス、ユーザID、サービス名など)を抜き出してインデックスイン登録してあげる必要があるということです。 あとから、ログ文字列の中から特定の項目を取り出して検索したい場合は、これまでの場合はスキーマにフィールドを追加してから、データの再登録(再インデックス)を行う必要がありました。

このderived fieldを利用することで、データ登録時ではなく、データ利用時にデータをパースしながら検索できるようになります(Schema on Read)。 あらかじめデータを切り出して保存する必要がないため、保存しているデータ量の削減ができます。その代わり、検索時に毎回パース処理が実行されるため検索時のコストが増えます。

derived fieldを定義する方法は2つ用意されています。

  1. インデックスのマッピングに定義する
  2. 検索リクエストで定義する

derivedフィールドの利用方法も現時点では2種類あります。

  1. 検索条件のフィールドとして利用
  2. 検索結果のデータに含めるフィールドとして利用
  • データ登録時点ではスキーマが決まっている必要がない
    • 通常、検索や集計に利用する場合は、登録時にスキーマ(フィールドの型)が決まっている必要がある
  • prefilter_fieldを指定することで、元のフィールドを用いた絞り込みを実行できる

前提条件

前提条件として、derivedの対象とする元のフィールドは以下を満たす必要があります。

  • _sourceもしくはdoc valuesが存在する(元データを取り出す必要があるため)
    • スクリプトを利用してデータを抜き出すから
  • search.allow_expensive_queries: trueが設定されている(クラスターの設定)
    • 処理が重くなるので、コストのかかるクエリがオフの設定の場合は動作しない仕組みとなっている(ソースコード
  • derivedが利用可能になっている
    • cluster/indexレベルでそれぞれオンオフの設定がある(どちらも動的設定)
      • indexレベル:index.query.derived_field.enabled
      • clusterレベル:search.derived_field.enabled
  • パフォーマンスに関する考慮が必要

何ができないの?

制限事項がいくつかあります。

  • Aggregationやスコアリング、ソートにはまだ未対応(対応する予定がありそう([META] Derived Fields #12281))
  • OpenSearch Dashboardの表示には未対応。フィルタリングは仕組みを知っていれば利用できる
  • derived fieldに対して、2つ以上のパースの設定はできない
  • join field typeは未対応
  • concurrent segment searchには未対応

データはどういう持ち方なの?

derived field自体はデータを持っているのではなく、次のような情報を定義する。

  • type : scriptでパースした後のデータの型を指定
  • script : フィールドのデータを作り出すためのスクリプト。基本的には元になる別のフィールドを使って、パースする処理となる

あとは、typeに合わせていくつかのオプションがあるが詳細はparametersを参照。

動作としては、検索時に検索対象となるデータに対して、scriptを実行してtypeに変換し、typeごとに対応するクエリの処理が実行され、derived fieldのクエリにヒットするかどうかの判定が行われる。 あくまでも検索時に実行される処理となるため、データ登録時には特にエラーなどは起きない。

ignore_malformedというオプションが用意されており、クエリ実行時にエラーがある場合はそのデータを無視するオプションが用意されています(詳細は後述)。

実際に検証してみる

公式ドキュメントに簡単なサンプルが用意されており、それを試してみることで簡単な動作の確認ができます。 ただし、次のような場合にどんな挙動になるのか?というのが気になったので確認してみました。 以下のサンプルとして提示するクエリはOpenSearch Dashboardで実行できる形式を模しています(私自身はVS CodeのRest Client拡張を普段利用しているのでそこから編集して転記しています)。

  1. 一部のデータに参照している元のフィールドがない場合にどうなるのか?
  2. 一部のデータがパースに失敗する場合はどうなるか?
  3. 上記のようなパースに失敗するデータがある場合に、それ以外のデータだけを対象とするような条件とderived fieldを利用した場合はどうなるか?

1. 一部のデータに参照している元のフィールドがない場合にどうなるのか?

公式ドキュメントのサンプルにあるデータは必ず対象となるフィールド(request)にデータが入っています。 対象となるフィールドがあったりなかったりする場合もあると思いますが、その時にどういう扱いになるかを確認します。 公式ドキュメントのサンプルインデックスに次のデータを追加します(公式ドキュメントのExample Setupから「Searching derived fields defined in index mappings」まで1度実施済みとします)。

PUT /logs/_doc/8
{ 
  "clientip": "61.177.2.0" 
}

データ登録時には特に何も言われません。derivedフィールドはデータ登録時には動作しないためです。

まずは、「検索結果のデータに含めるフィールドとして利用」するサンプルです。 検索リクエストで、fieldsパラメータにderivedフィールドを含めた全件検索を実施します。

GET /logs/_search
{
  "fields": ["request", "clientip", "timestamp"]
}

実施後のレスポンスにはエラーが出てきています(抜粋)。

{
  "error": {
    "root_cause": [
      {
        "type": "script_exception",
        "reason": "runtime error",
        "script_stack": [
          "org.opensearch.index.fielddata.ScriptDocValues$Strings.get(ScriptDocValues.java:555)",
          "org.opensearch.index.fielddata.ScriptDocValues$Strings.getValue(ScriptDocValues.java:571)",
          "emit(Long.parseLong(doc[\"request.keyword\"].value.splitOnToken(\" \")[0]))",
          "                                          ^---- HERE"
        ],
        "script": "emit(Long.parseLong(doc[\"request.keyword\"].value.splitOnToken(\" \")[0]))",
        "lang": "painless",
        "position": {
          "offset": 42,
          "start": 0,
          "end": 71
        }
      }
    ],
    "type": "search_phase_execution_exception",
    ...
    ]
  },
  "status": 400
}

同様に、クエリにderivedフィールドを利用する方法も試します。


GET /logs/_search
{
  "query": {
    "range": {
      "timestamp": {
        "gte": "1970-01-11T08:20:30.400Z",   
        "lte": "1970-01-12T08:26:00.400Z"
      }
    }
  },
  "fields": ["request", "clientip"]
}

こちらも同様のエラーが出ます。 scriptが動作した時に対象データがrequest.keywordというフィールドを持っていないためにエラーとなります。

公式ドキュメントのパラメータに、先ほども説明したignore_malformedというオプションがあります。 マッピングの修正用のリクエストです。デフォルトでは、ignore_malformedfalseになっているのでそれぞれに追加します。

PUT /logs/_mapping
Content-Type: application/json

{
  "derived": {
    "timestamp": {
      "type": "date",
      "format": "MM/dd/yyyy HH:mm:ss",
      "script": {
        "source": "emit(Long.parseLong(doc[\"request.keyword\"].value.splitOnToken(\" \")[0]))"
      },
      "ignore_malformed": true
    },
    "method": {
      "type": "keyword",
      "script": {
        "source": "emit(doc[\"request.keyword\"].value.splitOnToken(\" \")[1])"
      },
      "ignore_malformed": true
    },
    "size": {
      "type": "long",
      "script": {
        "source": "emit(Long.parseLong(doc[\"request.keyword\"].value.splitOnToken(\" \")[5]))"
      },
      "ignore_malformed": true
    }
  }
}

上記を実行後、先ほどの2種類(fieldsに指定する利用方法、クエリに利用する方法)を試してみると、クエリを利用する方法(rangeクエリ)については、フィールドが存在しないデータ以外のデータが検索に返ってくることがわかります。 ただ、残念ながら、fieldsに利用したクエリについては、同じエラーが出てしまいました。 ドキュメントのignore_malformedの説明には以下のような記述があります。

A Boolean value that specifies whether to ignore malformed values when running a query on a derived field.

残念ながらクエリの処理についてのみ対象としているのかもしれないですが、バグなような気も。。。(あとでIssue作っておくかな)

回避する方法についてはエラーにヒントが含まれています。 上のエラー例では省略しましたが、次のようなメッセージがエラーに含まれていました。

  "type": "illegal_state_exception",
  "reason": "A document doesn't have a value for a field! Use doc[<field>].size()==0 to check if a document is missing a field!"

公式のサンプルでは、簡略化もかねてなのかscriptの処理でいきなりemit()を呼び出していますが、安全のためにフィールドの存在確認をするほうがよいでしょう。 ということで、上記のチェック処理を入れたサンプルは次の通りです。 emit()の処理の外側にif文でフィールドのデータのサイズが0ではない(フィールドが存在する場合)という条件を記述することで、フィールドが存在しない場合は処理がなくなる仕組みとなります。

### インデックスマッピングでderivedを定義(ignore_malformed:trueかつフィールド存在チェック処理追加)
PUT /logs/_mapping
{
  "derived": {
    "timestamp": {
      "type": "date",
      "format": "MM/dd/yyyy HH:mm:ss",
      "script": {
        "source": "if(doc[\"request.keyword\"].size() != 0) {emit(Long.parseLong(doc[\"request.keyword\"].value.splitOnToken(\" \")[0]))}"
      },
      "ignore_malformed": false
    },
    "method": {
      "type": "keyword",
      "script": {
        "source": "if(doc[\"request.keyword\"].size() != 0) {emit(doc[\"request.keyword\"].value.splitOnToken(\" \")[1])}"
      },
      "ignore_malformed": false
    },
    "size": {
      "type": "long",
      "script": {
        "source": "if(doc[\"request.keyword\"].size() != 0) {emit(Long.parseLong(doc[\"request.keyword\"].value.splitOnToken(\" \")[5]))}"
      },
      "ignore_malformed": false
    }
  }
}

上記の修正後はエラーになっていたfieldsを指定するリクエストに関してもエラーにならなずに検索結果が返ってくるようになります。 request.keywordのないデータは検索結果にはtimestampは含まれていません。

{
        "_index": "logs",
        "_id": "8",
        "_score": 1.0,
        "_source": {
          "clientip": "61.177.2.0"
        },
        "fields": {
          "clientip": [
            "61.177.2.0"
          ]
        }
      }

2. 一部のデータがパースに失敗する場合はどうなるか?

フィールド自体が存在しない場合は検証しました。 次は想定していないデータが対象となるフィールドに存在する場合について考えます。

フィールド自体が存在しない場合は検証しました。 次は想定していないデータが対象となるフィールドに存在する場合について考えます。 1.で登録したデータや変更などを一度削除して、公式ドキュメントのサンプルインデックスに次のデータを追加します(公式ドキュメントのExample Setupから「Searching derived fields defined in index mappings」まで1度実施済みとします)。

PUT /logs/_doc/7
{ 
  "request": "hoge GET /english/images/france98_venues.gif HTTP/1.0 200 778", 
  "clientip": "61.177.2.0" 
}

1.の時と同様に、データ登録時には特にエラーなどは出ませんが、検索時に登録したデータが処理されるとエラーがでます。

GET /logs/_search
{
  "fields": ["request", "clientip", "timestamp"]
}

1.とは異なり、フィールド自体は存在しますがパース処理で失敗するため、次のようなエラーになります。

{
  "error": {
    "root_cause": [...
    ],
    "type": "search_phase_execution_exception",
    "reason": "all shards failed",
    "phase": "query",
    "grouped": true,
    "failed_shards": [
      {
        "shard": 0,
        "index": "logs",
        "node": "gKwPMrF9SHSfoNzKdMSWoA",
        "reason": {
          "type": "script_exception",
          "reason": "runtime error",
          "script_stack": [
            "java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)",
            "java.base/java.lang.Long.parseLong(Long.java:709)",
            "java.base/java.lang.Long.parseLong(Long.java:832)",
            "emit(Long.parseLong(doc[\"request.keyword\"].value.splitOnToken(\" \")[0]))",
            "                                                                  ^---- HERE"
          ],
          "script": "emit(Long.parseLong(doc[\"request.keyword\"].value.splitOnToken(\" \")[0]))",
          "lang": "painless",
          "position": {
            "offset": 66,
            "start": 0,
            "end": 71
          },
          "caused_by": {
            "type": "number_format_exception",
            "reason": "For input string: \"hoge\""
          }
        }
      }
    ]
  },
  "status": 400
}

caused_byの部分で「hoge」という文字列をLong.parseLong()で処理しようとしてエラーが出ていることがわかります。

こちらも1.と同様に、"ignore_malformed": trueを付与すると、検索クエリで該当データが出てきた場合はエラーが無視されてそれ以外のデータが検索結果に帰ってくるようになりますが、 fieldsに指定した場合はエラーになります。

次のようにtry-catchで例外発生時には値を返さない処理を追加するとエラーなく検索できるようになります。 もちろんエラーの出るデータについては検索結果には出てきません。

PUT /logs/_mapping
{  "derived": {
    "timestamp": {
      "type": "date",
      "format": "MM/dd/yyyy HH:mm:ss",
      "script": {
        "source": "if(doc[\"request.keyword\"].size() != 0) {try{emit(Long.parseLong(doc[\"request.keyword\"].value.splitOnToken(\" \")[0]))} catch(NumberFormatException e) {return}}"
      },
      "ignore_malformed": false
    },
    "method": {
      "type": "keyword",
      "script": {
        "source": "if(doc[\"request.keyword\"].size() != 0) {emit(doc[\"request.keyword\"].value.splitOnToken(\" \")[1])}"
      },
      "ignore_malformed": false
    },
    "size": {
      "type": "long",
      "script": {
        "source": "if(doc[\"request.keyword\"].size() != 0) {try{emit(Long.parseLong(doc[\"request.keyword\"].value.splitOnToken(\" \")[5]))} catch(NumberFormatException e) {return}}"
      },
      "ignore_malformed": false
    }
  }
}

3. 上記のようなパースに失敗するデータがある場合に、それ以外のデータだけを対象とするような条件とderived fieldを利用した場合はどうなるか?

derivedフィールドは検索時に処理が実行されます。 ということは、1.や2.でエラーとなるデータを追加していても、そのデータが含まれない検索条件で検索した場合はエラーが出ないと想定されます。 こちらも確認してみましょう。

まずは、公式ドキュメントのサンプルデータの状態に戻します。公式ドキュメントのサンプルインデックスに次のデータを追加します(公式ドキュメントのExample Setupから「Searching derived fields defined in index mappings」まで1度実施済みとします)。 次に、1.と2.のデータを追加します。

PUT /logs/_doc/7
{ 
  "request": "hoge GET /english/images/france98_venues.gif HTTP/1.0 200 778", 
  "clientip": "61.177.2.0" 
}

PUT /logs/_doc/8
{ 
  "clientip": "61.177.2.0" 
}

この状態だと、次の検索ではエラーが発生します。これまで説明したとおりです。

GET /logs/_search
{
  "fields": ["request", "clientip", "timestamp"]
}

例外の発生するデータがヒットしないクエリを追加して実行するとエラーなく結果が返ってきます。

GET /logs/_search
{
  "query": {
    "bool": {
      "must_not": [
        {
          "ids": {
            "values": [7,8]
          }
        }
      ]
    }
  },
  "fields": ["request", "clientip", "timestamp"]
}

まとめ

新しく追加されたderivedフィールドについて簡単に調べてみました。 きれいなデータであればスクリプトもシンプルですみますが、検索時にエラーが出ないようにする場合は、きちんと各所でチェックを入れるスクリプトの書き方が重要になってくるかと思います。 とくにignore_malformedの挙動が検索クエリの時のみに限定されているので、安全なスクリプトにするほうが良いと思います。

また、インデックスのmappingsにderivedフィールドを定義する場合は、定義のタイミングではコンパイルなどはされないため構文エラーなどの発生もすべて検索するタイミングとなります。 さらに、後から来たデータでエラーになることも考えられますので、利用するのは注意しながらがよいかなと思います。

ただ、これまでよりも柔軟にデータ登録してあとから検索を考えるなどができるので便利になりそうですね。

残課題

  • JSONオブジェクトをderivedフィールドに指定する例もドキュメントにあるのでそれの使用感
  • 大量データを入れて、速度的にどのくらい影響が出るのか?
    • prefilter_fieldを使うといい感じになりそうか?
  • 検索時にエラーにならないスクリプトの書き方の説明をしたが、エラーになるようなデータがあるかどうかを調べるにはどうするか?
  • derivedフィールドが使えないバージョンで作成したインデックスに対してderivedフィールドを作ろうとするとどうなるのか?
    • おそらくエラーになると思われる。ローカルでインデックス作ってデータを入れて、バージョンアップをして検証してみる?

comments powered by Disqus

See Also by Hugo


Related by prelims-cli