mattintosh note

技術書典に出たい人生だった…

Elasticsearch とオブジェクト指向。Object datatype と Nested datatype の違い

Elasticsearch のデータタイプには Object datatype と Nested datatype というものがある。

これを説明する前にオブジェクト指向な考え方について知っておかなければいけないが、これを書いている人間はつい最近まで「オブジェクト指向とはなんぞや?」という人間だったため詳しい人からすると誤った考え方をしているかもしれないのでその辺をご容赦いただいた上で読んでもらいたい。

オブジェクト指向的なデータの作り方

オブジェクト指向的な考え方の場合、データを下記のような構造で作成すると思う。こういうデータ構造が出来ないわけではないし間違いでもない。

{
  "first_name": "John",
  "last_name": "Smith"
}

ではオブジェクト指向的な考えでデータを入れるならどういう構造になるだろうか?first_namelast_name も名前を構成する部品なので「名前」にぶら下げるのがいいのだろう。

{
  "name": {
    "first": "John",
    "last": "Smith"
  }
}

「え、なんか面倒臭くないですか!?」なんて思ったりするかもしれない。この JSON を見るとネストされて若干複雑になっていたりキーも増えているし、デメリットしか無さそうに思える。(そう思っていた時期が私にはありました)

しかし、フィールドへのアクセスはフラットな場合とほとんど変わらない。

name.first
name.last

データの見方を変えてみる

JSON のままではわかりづらいのでそれぞれを表にしてみよう。

最初に書いた非オブジェクト指向的な考え方の場合、フィールドはフラットな関係なのでこんな感じになる。よくある表だ。

オブジェクト指向的な考え方
user first_name (string) last_name (string)
John Smith John Smith
Alice White Alice White

ではオブジェクト指向的な考えで作成した JSON を表にしてみよう。

オブジェクト指向的な考え方①
user name (object)
first (string) last (string)
John Smith John Smith
Alice White Alice White

あるいはこういうイメージの仕方かもしれない。

オブジェクト指向的な考え方②
user name (object)
John Smith first (string) last (string)
John Smith
Alice White first (string) last (string)
Alice White

Excel とかを使う人であればセルの結合はよく使っていると思うのですぐイメージ出来ると思う。非オブジェクト指向的な考え方であってもデータベースでよく見る構造だし間違っちゃいない。でも、オブジェクト指向的な考え方をすることによってそれぞれのフィールドに関連性をもたせることが出来るようになる。

さらに「オブジェクト」というものを意識した場合はこんな感じになるだろうか。「name」フィールドには直接「first」や「last」のデータが入っているわけではなく、「name Object」というものが入っていて、その中に「first」と「last」というフィールドが用意されているのだ。

オブジェクト指向的な構造
user name (object)
John Smith
"name" Object
first (string) last (string)
John Smith
Alice White
"name" Object
first (string) last (string)
Alice White

オブジェクト指向的な構造の場合、

「John Smith さんの『姓』のデータと『名』のデータをちょうだい」

と、2つのお願いをしなくてはいけない。また、我々人間には『姓』と『名』から『名前』という関連性をイメージできるが、機械からすれば『姓』と『名』の関連性はわからないだろう。

オブジェクト指向的な構造であれば

「John Smith さんの『名前』のデータちょうだい」

と、お願いすればあとは自分で好きに出来るし、機械からしても『姓』と『名』がどういうものかはわからないが『名前』というものに紐付いた何かなんだろう、くらいには感じるんじゃなかろうか。

Elasticsearch にネストしたデータを入れてみる

Elasticsearch にネストされたデータを入れた場合、特にマッピングの設定をしていなくても Dynamic templates がよしなにやってくれる。

f:id:mattintosh4:20190226162254p:plain
Kibana の Dev Tools でサンプルデータを投入してみる

PUT my_index/_doc/1
{
  "name": {
    "first": "John",
    "last": "Smith"
  }
}

PUT my_index/_doc/2
{
  "name": {
    "first": "Alice",
    "last": "White"
  }
}

GET my_index/_search

マッピングはこんな感じになる。通常、フィールド名のすぐ下には type の指定が来るが、ネストしている場合は ❶ の部分に properties が来る。これが Elasticsearch で言うところの Object datatype である。型指定は下層の値を格納する ❷ の部分で設定する。

GET my_index/_mapping
{
  "my_index" : {
    "mappings" : {
      "_doc" : {
        "properties" : {
          "name" : {
            "properties" : { ❶
              "first" : { ❷
                "type" : "text",
                "fields" : {
                  "keyword" : {
                    "type" : "keyword",
                    "ignore_above" : 256
                  }
                }
              },
              "last" : { ❷
                "type" : "text",
                "fields" : {
                  "keyword" : {
                    "type" : "keyword",
                    "ignore_above" : 256
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Lucene で検索する場合は親と子のフィールドを . で連結する。

name.first:John AND name.last:Smith

Object datatype と Nested datatype について知る

特にマッピングをせずにデータを投入した場合、ネストされたデータは Object datatype になる。検索も普通に効くし、特に問題が無いように思われるが、実は投入した _source の構造と Elasticsearch の内部での構造が異なってしまうことがある。

例えば、以下のように students フィールドを配列にして複数のデータを投入したとする。

PUT my_index/_doc/1
{
  "students": [
    {
      "first": "John",
      "last": "Smith"
    },
    {
      "first": "Alice",
      "last": "White"
    }
  ]
}

これ、入れたとおりの構造になっているかと思いきや、Elasticsearch の内部ではこういう風に解釈されているのである。

{
  "students": {
    "first": ["John", "Alice"],
    "last": ["Smith", "White"]
  }
}

students.first:Johnstudents.last:Smith で検索すればちゃんとマッチするし、特に問題ないのでは?と思うが、誤った結果を招くこともある。

例えば、本来は存在しない「John White」という人を検索してみるとする。

"John White" を検索するクエリ

GET my_index/_search
{
  "query": {
    "query_string": {
      "query": "students.first:John AND students.last:White"
    }
  }
}

「John White」という人はいないので結果は0件であることが期待されるが、Elasticsearch からすれば、students.first:Johntrue を返すし、students.last:Whitetrue を返すので先程投入したデータが返ってくる。

{
  "took" : 17,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 0.5753642,
    "hits" : [
      {
        "_index" : "my_index",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.5753642,
        "_source" : {
          "students" : [
            {
              "first" : "John",
              "last" : "Smith"
            },
            {
              "first" : "Alice",
              "last" : "White"
            }
          ]
        }
      }
    ]
  }
}

このような結果にならないように、データ構造を維持しておけるデータタイプが Nested datatype である。ネストされたオブジェクトを Nested datatype として扱いたい場合はマッピングnested を指定する。

PUT my_index/
{
  "mappings": {
    "_doc": {
      "properties": {
        "students": {
          "type": "nested"
        }
      }
    }
  }
}

「データ構造を維持したまま保存できる Nested datatype の方が Object datatype よりもよいのではないか?」と思うが、Nested datatype では Lucene などからネストされたフィールドに対して検索が出来ないという制限があるため、student.first:John といった感じの気軽な検索が出来なくなる。

Nested datatype のフィールドの検索には Nested Query という方法が用意されていて、使い方としてはネストの親を path で指定し、そこを基点に検索するという感じ。

では、nested されたフィールドに検索をかけて誤った検索結果が返ってこないか試してみよう。

"Nested Query" のサンプル

# John Smith の検索
GET my_index/_search
{
  "query": {
    "nested": {
      "path": "students",
      "query": {
        "query_string": {
          "query": "students.first:John AND students.last:Smith"
        }
      }
    }
  }
}

# John White の検索(存在しないので何も出ない)
GET my_index/_search
{
  "query": {
    "nested": {
      "path": "students",
      "query": {
        "query_string": {
          "query": "students.first:John AND students.last:White"
        }
      }
    }
  }
}

きっと期待通りの結果が得られると思う。

Kibana での見え方

Kibana では「配列内のオブジェクトはうまくサポートしない」と表示されるが、この表示は Object datatype も Nested datatype も同じ。

f:id:mattintosh4:20190226205542p:plain
Kibana - 配列内のオブジェクトの扱い

Index Patterns を見ても下層のフィールドはきちんと登録されるので nested になっているかどうかはマッピング情報を見ないとわからない。

f:id:mattintosh4:20190226233544p:plain
Kibana - Index Patterns

Nested datatype のメリットを活かしつつ Lucene も使いたい

Nested datatype で Lucene が使えないということは Kibana の検索バーなどからも検索が出来なくなってしまうということであり、これは結構痛い。(これは Grafana も同様だったが、こちらは issue に要望が上がっていたので近々サポートされるかもしれない)

対応策として、copy_to でトップレベルの任意のフィールドに値をコピーしておくという方法がある。

下記は students.firstfirst_names に、students.lastlast_names にコピーするマッピングの例。

PUT my_index/
{
  "mappings": {
    "_doc": {
      "properties": {
        "students": {
          "type": "nested",
          "properties": {
            "first": {
              "type": "text",
              "copy_to": "first_names", ❶
              "fields": {
                "keyword": {
                  "type": "keyword",
                  "ignore_above": 256
                }
              }
            },
            "last": {
              "type": "text",
              "copy_to": "last_names", ❷
              "fields": {
                "keyword": {
                  "type": "keyword",
                  "ignore_above": 256
                }
              }
            }
          }
        }
      }
    }
  }
}

ツリーで書くとこんな感じだろうか。

_doc
|
+-- students
|   |
|   +-- [0]
|   |   |
|   |   +-- first -> copy_to: first_names ❶
|   |   |
|   |   +-- last  -> copy_to: last_names ❷
|   |
|   +-- [1]
|       |
|       +-- first -> copy_to: first_names ❶
|       |
|       +-- last  -> copy_to: last_names ❷
|
+-- first_names ❶
|
+-- last_names ❷

名前のデータを入れてからマッピングを見てみると _source には存在しない first_nameslast_names のフィールドが増えているのがわかる。

{
  "my_index" : {
    "mappings" : {
      "_doc" : {
        "properties" : {
          "first_names" : {
            "type" : "text",
            "fields" : {
              "keyword" : {
                "type" : "keyword",
                "ignore_above" : 256
              }
            }
          },
          "last_names" : {
            "type" : "text",
            "fields" : {
              "keyword" : {
                "type" : "keyword",
                "ignore_above" : 256
              }
            }
          },
          "students" : {
            "type" : "nested",
            "properties" : {
              "first" : {
                "type" : "text",
                "fields" : {
                  "keyword" : {
                    "type" : "keyword",
                    "ignore_above" : 256
                  }
                },
                "copy_to" : [
                  "first_names"
                ]
              },
              "last" : {
                "type" : "text",
                "fields" : {
                  "keyword" : {
                    "type" : "keyword",
                    "ignore_above" : 256
                  }
                },
                "copy_to" : [
                  "last_names"
                ]
              }
            }
          }
        }
      }
    }
  }
}

表にするとこんな感じだろうか。

my_index/_doc
students (object array) first_names (array) last_names (array)
Object[0]
first (string) last (string)
John Smith
Object[1]
first (string) last (string)
Alice White
  • John
    -> /students[0]/first
  • Alice
    -> /students[1]/first
  • Smith
    -> /students[0]/last
  • White
    -> /students[1]/last

Kibana で検索する場合は copy_to で作成したフィールドに対して検索をかければよい。

first_names:John

余談だが、copy_to はコピー先を複数指定することができるので、例えば姓と名の両方を集約したフィールドを作成することもできる。下記のように「❶ 名前検索」、「❷ 名字検索」用のフィールドに加えて「❸ 氏名検索」用のフィールドへコピーしてあげればよい。

        :
        "students": {
          "type": "nested",
          "properties": {
            "first": {
              "type": "text",
              "copy_to": [
                "first_names", ❶
                "full_names" ❸
              ],
              "fields": {
                "keyword": {
                  "type": "keyword",
                  "ignore_above": 256
                }
              }
            },
            "last": {
              "type": "text",
              "copy_to": [
                "last_names", ❷
                "full_names" ❸
              ],
              "fields": {
                "keyword": {
                  "type": "keyword",
                  "ignore_above": 256
                }
              }
            }
          }
        }
        :

メモ:copy_to のデータの取り出し方

copy_to で作成したフィールドは _source に含まれないので script_fields または docvalue_fields で取り出す必要がある。

この2つはデフォルトの _source の返し方が異なるので必要に応じて設定しておく。また、取り出したフィールドは _source 内の配列順とは限らないので注意。

リクエスト方法 _source
scripted_fields false
docvalue_fields true

scripted_field を使って copy_to のコピー先のフィールドを出力するクエリ

GET my_index/_search
{
  "_source": true,
  "script_fields": {
    "任意のフィールド名": {
      "script": {
        "source": "doc['first_names.keyword'].values"
      }
    }
  }
}
{
  "took" : 38,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "my_index",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "_source" : {
          "students" : [
            {
              "first" : "John",
              "last" : "Smith"
            },
            {
              "first" : "Alice",
              "last" : "White"
            }
          ]
        },
        "fields" : {
          "任意のフィールド名" : [
            "Alice",
            "John"
          ]
        }
      }
    ]
  }
}

docvalue_fields では field をそのまま指定すればいい。format には use_field_mapping を指定しておけばマッピングを元に決めてくれる。

docvalue_fields を使って copy_to のコピー先のフィールドを出力するクエリ

GET my_index/_search
{
  "_source": false, 
  "docvalue_fields": [
    {
      "field": "first_names.keyword",
      "format": "use_field_mapping"
    },
    {
      "field": "last_names.keyword",
      "format": "use_field_mapping"
    }
  ]
}
{
  "took" : 13,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "my_index",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "fields" : {
          "first_names.keyword" : [
            "Alice",
            "John"
          ],
          "last_names.keyword" : [
            "Smith",
            "White"
          ]
        }
      }
    ]
  }
}

あとがき

この記事は会社で Elasticsearch に入れるデータ構造の説明をするときに使えればいいかなと思って書いたものです。「リレーショナル・データベースでは JOIN したりするところを Elasticsearch ならネストして入れておけるよ」とか「ネストするときは場合によって内部の解釈がこうなるから注意だよ」というのを「これ読めば多分わかるよ」で済ませられたらな、と😗

Elasticsearch にデータを入れるときにオブジェクト指向を意識するというのは私が個人的に念頭においていることで、必ずしもネスト構造にしなければならないわけではありません。(エキスパートの方々がどう思っているかは知りませんが)

しかし、私はネストさせたデータが使えることを知って、いままで以上に Elasticsearch が使いやすくなったと感じています。

久しぶりにツイッターでブログの宣伝したらこの記事にリツートやいいねをいただけたのでもう少しなんか書いておこうかと思います。

Elasticsearch はネストされているデータでも対応してくれるので外部ソースをわざわざ整形してフラットにしなくても大丈夫

私は以前、Python の psutil で状態を拾うときに、例えば psutil.virtual_memory() からひとつずつ変数などに格納していました。(冒頭に書いたようにオブジェクト指向という考えがなかったので)

virtual_memory = psutil.virtual_memory()
data = {
  'virtual_memory_total': virtual_memory.total,
  'virtual_memory_available': virtual_memory.available,
  :
  中略
  :
}
print(json.dumps(data, indent=2))
{
  'virtual_memory_total': 12471726080,
  'virutal_memory_available': 6535933952,
  :
  中略
  :
}

しかしこれ、辞書に変換してそのまま突っ込んでしまえばよかったのです。

import json
import psutil

print(json.dumps({'virtual_memory': psutil.virtual_memory()._asdict()}, indent=2))
{
  "virtual_memory": {
    "total": 12471726080,
    "available": 6535933952,
    "percent": 47.6,
    "used": 5011636224,
    "free": 3222216704,
    "active": 6381834240,
    "inactive": 2112024576,
    "buffers": 629141504,
    "cached": 3608731648,
    "shared": 700723200
  }
}

これは私が Elasticsearch を普通のデータベースと同じようなものだと思っていて、ネストされたデータが入るということを知らなかったからです。今の私ならこれらのデータを丸ごと突っ込んで、仮に不要なフィールドがあれば「整形するの面倒だからとりあえずソースのまま突っ込んで、要らないフィールドなら Elasticsearch のマッピングindex: false すればいいでしょ」と思っています。

ネストされたデータが配列の場合は Object datatype と Nested datatype で解釈が変わることを知っておく

私は最初「Elasticsearch にネストさせて入れてみたい。ネストされたデータということなら "elasticsearch nested" でググれば情報が出てくるだろう」と、Nested datatype を先に見つけてしまったため、Object datatype の存在を見逃してしまいました。そのため、Lucene での検索が効かなくなってしまい「Nested datatype ってデメリットしか無いのでは?🤔」と思ってしまいました。その後、Elasticsearch のドキュメントを順番に見ていくうちに Object datatype を発見しましたが、今度は Object datatype と Nested datatype の違いに悩まされました。(これは内部解釈の部分を見て理解できました)

Nested datatype が必要になる場合って、公式のサンプルのような配列内オブジェクトが複数フィールドを持っていて、それらを厳密に組み合わせて扱いたい場合かなと思います。いまのところ私はそのようなデータを扱う機会がなく、Object datatype の方が検索におけるメリットが多いため Nested datatype はほとんど使っていません。

それでは良い Elasticsearch ライフを😊