Elasticsearch のデータタイプには Object datatype と Nested datatype というものがある。
- Object datatype | Elasticsearch Reference [6.6] | Elastic
- Nested datatype | Elasticsearch Reference [6.6] | Elastic
これを説明する前にオブジェクト指向な考え方について知っておかなければいけないが、これを書いている人間はつい最近まで「オブジェクト指向とはなんぞや?」という人間だったため詳しい人からすると誤った考え方をしているかもしれないのでその辺をご容赦いただいた上で読んでもらいたい。
オブジェクト指向的なデータの作り方
非オブジェクト指向的な考え方の場合、データを下記のような構造で作成すると思う。こういうデータ構造が出来ないわけではないし間違いでもない。
{ "first_name": "John", "last_name": "Smith" }
ではオブジェクト指向的な考えでデータを入れるならどういう構造になるだろうか?first_name
も last_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 |
|
||||
Alice White |
|
非オブジェクト指向的な構造の場合、
「John Smith さんの『姓』のデータと『名』のデータをちょうだい」
と、2つのお願いをしなくてはいけない。また、我々人間には『姓』と『名』から『名前』という関連性をイメージできるが、機械からすれば『姓』と『名』の関連性はわからないだろう。
オブジェクト指向的な構造であれば
「John Smith さんの『名前』のデータちょうだい」
と、お願いすればあとは自分で好きに出来るし、機械からしても『姓』と『名』がどういうものかはわからないが『名前』というものに紐付いた何かなんだろう、くらいには感じるんじゃなかろうか。
Elasticsearch にネストしたデータを入れてみる
Elasticsearch にネストされたデータを入れた場合、特にマッピングの設定をしていなくても Dynamic templates がよしなにやってくれる。
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:John
や students.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:John
は true
を返すし、students.last:White
も true
を返すので先程投入したデータが返ってくる。
{ "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 も同じ。
Index Patterns を見ても下層のフィールドはきちんと登録されるので nested
になっているかどうかはマッピング情報を見ないとわからない。
Nested datatype のメリットを活かしつつ Lucene も使いたい
Nested datatype で Lucene が使えないということは Kibana の検索バーなどからも検索が出来なくなってしまうということであり、これは結構痛い。(これは Grafana も同様だったが、こちらは issue に要望が上がっていたので近々サポートされるかもしれない)
対応策として、copy_to
でトップレベルの任意のフィールドに値をコピーしておくという方法がある。
下記は students.first
を first_names
に、students.last
を last_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_names
と last_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" ] } } } } } } } }
表にするとこんな感じだろうか。
students (object array) | first_names (array) | last_names (array) | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
|
|
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 ライフを😊