前回に引き続き学習したことをまとめていきます。
今回はデータを格納するためのインデックスとマッピングについて解説していきます。
記事のリストは下に貼っておきます。
■記事一覧
■利用環境
- Docker
- Elasticsearch 8.6.2
- Kibana 8.6.2
■セットアップ手順
記事を読むだけじゃなくて実際に動かしたい場合はElasticsearchノードとKibanaをセットアップしてください。
基本的には公式を見てもらう感じですが一応以前の記事を貼っておきます。
https://qiita.com/Schott_man/items/4707973f0d6fe1d0a61f#%E3%81%BE%E3%81%9A%E3%81%AF%E8%A7%A6%E3%81%A3%E3%81%A6%E3%81%BF%E3%82%8B
インデックスとは
まずインデックスから説明します。
インデックスはRDBでいうところのテーブルだと説明されることが多いです。
データ(ドキュメントといいます)が格納される対象だからですね。
テーブルに比喩されるように、ドキュメントのInsert, Update, Deleteなどを実施することができます。
ただし、ここで重要なのはインデックスはあくまでもドキュメント保存のための論理的な構成要素だということです。
インデックスに対して保存処理を実行する際、データの実体はシャードという単位に保存されています。
シャードとはApache Luceneという検索ソフトウェアのインスタンスであり、インデックスに対して実行された処理は実際にはこのシャード群に対して実行されます。
このような構成にすることで複数のElasticsearchサーバー(ノードといいます)にまたがって大量のデータを保存したり利用したりしたい場合でも、インデックスに対して操作をリクエストするだけで各シャードが動いて計算結果を集約して返してくれるので、実際に各サーバーに分散して配置されたシャードのことを意識する必要がありません。
また、ドキュメント量が均一になるようにサーバー間で分散させたり、シャード全体にわたって処理を分散および並列化することでパフォーマンスを向上させるメリットもあります。
なお、デフォルトではドキュメントに一意に付与される(あるいは付与する)_idの値のハッシュ値を元にを計算して保存対象のシャードを決定します。
Dynamic MappingとExplicit Mapping
インデックスに作成されたドキュメントをどのように保存し項目定義をするか決定するためにマッピングを設定します。
マッピングにはDynamic MappingとExplicit Mappingの2つの種類があります。
Dynamic Mapping
Dynamic Mappingはインデックス定義をあらかじめ設定していなくてもドキュメントの作成時にElasticsearchが自動的にインデックスや項目定義を作成してくれる機能です。
下の例ではdynamic_testという現在Elasticsearchに存在しないインデックスに対してドキュメントを作成しようとしています(インデックス名には小文字しか使えません)。
POST dynamic_test/_doc/
{
"user_name": "schott_man",
"user_id": 1,
"crated_at": "2023-03-01T10:04:40.241",
"text": "サンプルテキストです。"
}
これをKibanaのDev Toolsから実行すると成功のレスポンスが返却され、インデックスとドキュメントがそれぞれ作成されます。
{
"_index": "dynamic_test",
"_id": "N8i9FocB_r85x3P-3tmH",
"_version": 1,
"result": "created",
"_shards": {
"total": 2,
"successful": 1,
"failed": 0
},
"_seq_no": 0,
"_primary_term": 1
}
GET index名 で作成されたインデックスの定義情報を確認できるので見てみます。
GET dynamic_test
{
"dynamic_test": {
"aliases": {},
"mappings": {
"properties": {
"crated_at": {
"type": "date"
},
"text": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"user_id": {
"type": "long"
},
"user_name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
},
"settings": {
"index": {
"routing": {
"allocation": {
"include": {
"_tier_preference": "data_content"
}
}
},
"number_of_shards": "1",
"provided_name": "dynamic_test",
"creation_date": "1679713754674",
"number_of_replicas": "1",
"uuid": "il7lgnniQaWVwxmNWScZ3w",
"version": {
"created": "8060299"
}
}
}
}
}
特にこちらから指定していないにも関わらずsettings(インデックス設定)やmappingsが作られています。
mappingsに関して言えば、自動的にデータ型を推論して項目定義も作成されています。
このようにDynamic Mappingでは存在しないインデックスや項目を指定してドキュメントを作成すると自動的にElasticsearchがインデックスや項目定義を作成してくれます。
ちなみに、指定したインデックスは存在するけど項目は無い場合にはDynamic Mappingで自動的に項目定義だけが指定インデックスに作成されます。
身に覚えの無いインデックスや項目ができてる時はこの機能によるものだと考えて良いです。(タイポで勝手に項目作られてめんどくさいこととかある。。)
なお、Dynamic Mapping時のデータ型を設定するためにmappingsにはdynamicというパラメータが存在します。
与えられたJSONのデータ型とこのパラメータの設定値に応じてどのように項目の型を設定するかが決定します。
デフォルトはdynamic: trueです。(ここからtrueの時の型変換表を確認できます)
Explicit Mapping
Dynamic Mappingとは異なり、こちらは事前にマッピングを定義してインデックスを作成します。
下記の例ではインデックス explicit_testを作成し、同時に項目定義も作成しています。
PUT explicit_test
{
"mappings": {
"properties": {
"user_name": {
"type": "integer"
},
"user_id": {
"type": "keyword"
},
"crated_at": {
"type": "date"
},
"text": {
"type": "text"
}
}
}
}
実際にはDynamic Mappingよりも自分の思う通りに型を定義しておけるExplicit Mappingのほうが利用機会は多いと思います。
上のは簡単な例ですが、mappings以外にもindexsettingやアナライザーなどを定義するにはExplicit Mappingを使用することになります。
mapppingsに設定できるデータ型
基本的なデータ型
Elasticsearcn固有のというよりは一般的な型ですが下記のようなものがあります。
- Boolean
真偽値を持つ型です。
true, false, ""(empty)が値として許可されています。なお、""(empty)はFalseとして扱われます。
値を""で括っても括らなくても動作します。 - Numeric
数値を扱う型の総称です。
integer, short, long, float, doubleなどなど他にもありますがほとんどの数値型は揃っています。 - Keyword
文字列を扱うデータ型その1です。
このデータ型に保存された文字列からは転置インデックスが作成されません。
そのため、ID値や電話番号、メールアドレスなどの値を文字列として保管する際によく使用されます。
完全一致で検索できればよい値の場合はよくこれを使います。 - Text
文字列を扱うデータ型その2です。
Keywordとは異なり、このデータ型として格納された文字列は形態素解析を実施されて転置インデックスが作成されます。
どのように形態素解析をされるのかはアナライザーによって決定します。 - Date
日付を扱うデータ型です。
日付けのみ、日付+秒、エポックミリ秒など様々な形式で日付を保存できます。
保存できる日付のフォーマットはこちらの通りです。 - Arrays
配列を扱うデータ型です。
格納されるすべての値は同じデータ型である必要があります。
階層構造を持つのに使用する型
Object
Object型を使用するとJSONのオブジェクトのように階層構造を持たせることができます。下記の例でいうactorとnameがObjectです。
PUT object_test
{
"mappings": {
"properties": {
"title": {
"type": "keyword"
},
"actor": {
"properties": {
"name": {
"properties": {
"first": {
"type": "keyword"
},
"last": {
"type": "keyword"
}
}
},
"age": {
"type": "integer"
}
}
}
}
}
}
こんなふうにObject型には配列でデータを格納することもできます。
POST object_test/_doc
{
"title": "The Silence of the Lambs",
"actor":[
{
"name":{
"first": "Jodie",
"last": "Foster"
},
"age": 60
},
{
"name":{
"first": "Anthony",
"last": "Hopkins"
},
"age": 85
}
]
}
ただし、Object型を配列として使用する際には注意が必要です。
object_testインデックスにさらに下記のドキュメントを追加します。
POST object_test/_doc
{
"title": "Thor",
"actor":[
{
"name":{
"first": "Christopher",
"last": "Hemsworth"
},
"age": 39
},
{
"name":{
"first": "Anthony",
"last": "Hopkins"
},
"age": 85
}
]
}
ここで、ラストネームが"H"で始まり、かつ年齢が70歳以下の俳優が出演する映画を検索するとします。
その場合、先ほど登録したマイティーソー(Thor)はクリス・ヘムズワースが条件を満たすためヒットしますが、羊たちの沈黙(The Silence of the Lambs)はヒットしないはずです。
GET object_test/_search
{
"query": {
"bool": {
"filter": [ --> AND検索用のクエリ
{
"range": { --> 条件その1: 70歳以下
"actor.age": {
"lte": 70
}
}
},
{
"wildcard": { ---> 条件その2: lastがHから始まる
"actor.name.last": {
"value": "H*"
}
}
}
]
}
}
}
しかし、結果は2件ともヒットします。
"hits": [
{
"_index": "object_test",
"_id": "OMgHGYcB_r85x3P-C9kH",
"_score": 0,
"_source": {
"title": "The Silence of the Lambs",
"actor": [
{
"name": {
"first": "Jodie",
"last": "Foster"
},
"age": 60
},
{
"name": {
"first": "Anthony",
"last": "Hopkins"
},
"age": 85
}
]
}
},
{
"_index": "object_test",
"_id": "OcgzGYcB_r85x3P-Dtm1",
"_score": 0,
"_source": {
"title": "Thor",
"actor": [
{
"name": {
"first": "Christopher",
"last": "Hemsworth"
},
"age": 39
},
{
"name": {
"first": "Anthony",
"last": "Hopkins"
},
"age": 85
}
]
}
}
]
}
このようになるのはObject型のデータが内部的に配列としてフラットに保存されていることに起因します。
羊たちの沈黙のドキュメントを例にするとこんな感じです。
{
"title": "The Silence of the Lambs",
"actor.name.first": ["Jodie", "Anthony"],
"actor.name.last": ["Foster", "Hopkins"],
"actor.age": [60, 85]
}
Objectの配列の値同士が結びつきを保持していないのが分かると思います。
ジョディフォスターが60歳でアンソニーホプキンスが85歳であると保存しているわけではないのですね。
今回のようにObjectを配列として利用しており、なおかつ「Objectの項目を複数利用して検索をしたい」場合には意図しないドキュメントまでヒットさせてしまう恐れがあります。
それだと困るケースがもちろんあるので、Objectの配列の値ごとの結びつきを保持させるためのNestedという型が存在します。
Nested
Objectを配列として扱いたい場合はNested型を利用します。
Object型は内部的に各項目値を配列として平坦化して保持していましたが、Nestedは各Objectを個別のドキュメントとしてインデックスします。
actorは配列として保持したいのでNested型にして新しくnested_testインデックスを作成します。
PUT nested_test
{
"mappings": {
"properties": {
"title": {
"type": "keyword"
},
"actor": {
"type": "nested",
"properties": {
"name": {
"properties": {
"first": {
"type": "keyword"
},
"last": {
"type": "keyword"
}
}
},
"age": {
"type": "integer"
}
}
}
}
}
}
先ほどと同じドキュメントを追加します。
POST nested_test_test/_doc
{
"title": "The Silence of the Lambs",
"actor":[
{
"name":{
"first": "Jodie",
"last": "Foster"
},
"age": 60
},
{
"name":{
"first": "Anthony",
"last": "Hopkins"
},
"age": 85
}
]
}
POST nested_test/_doc
{
"title": "Thor",
"actor":[
{
"name":{
"first": "Christopher",
"last": "Hemsworth"
},
"age": 39
},
{
"name":{
"first": "Anthony",
"last": "Hopkins"
},
"age": 85
}
]
}
再びラストネームが"H"で始まり、かつ年齢が70歳以下の俳優が出演する映画を検索してみます。
GET nested_test/_search
{
"query": {
"nested": { ---> Nested型の項目を使用した検索を行う場合はnested queryでラップする
"path": "actor",
"query": {
"bool": {
"filter": [
{
"range": {
"actor.age": {
"lte": 70
}
}
},
{
"wildcard": {
"actor.name.last": {
"value": "H*"
}
}
}
]
}
}
}
}
}
期待通りマイティソーだけヒットしました。
{
"took": 1,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 0,
"hits": [
{
"_index": "nested_test",
"_id": "O8iUHIcB_r85x3P-0dmY",
"_score": 0,
"_source": {
"title": "Thor",
"actor": [
{
"name": {
"first": "Christopher",
"last": "Hemsworth"
},
"age": 39
},
{
"name": {
"first": "Anthony",
"last": "Hopkins"
},
"age": 85
}
]
}
}
]
}
}
こうみると全てNested型を使えばよい気がしますが、そんなことは決してありません。
Nested型を利用すると前述の通り配列内のObjectも内部的にドキュメントとしてインデックスするので各種パフォーマンスに影響を与えます。
そのため、パフォーマンスの低下を避けるため、インデックスあたりのネストされたフィールドの数は50に制限され、ドキュメントあたりのネストされたオブジェクトの数は10000に制限されています(制限値は変更できますが推奨はされていないようです)。
Nestedを使用しないでも何とかなる局面では極力使用しないようにしましょう。
今回の例で言えば、actor内のname項目は配列として持つ必要は無いのでNestedにしていません。
Flattened
基本的にはObject型かNested型で問題無いと思いますが、少し特殊なデータ型も紹介します。
あらかじめどのようなマッピングが必要か分からずドキュメントが作成されるたびに新しい項目が増えてしまうとマッピング爆発が発生し、慢性的なメモリ不足や復旧が不可能な状態に陥ることがあります。
そのような時は選択肢としてFlattend型を検討してみてください。
Object型やNested型は子のフィールドをそれぞれ保持していましたが、Flattened型の項目は子のフィールドを文字列の配列として保持します。そのため、マッピングが増えても配列の要素が増えるだけなのでマッピング爆発は起きません。
言葉だけで説明してもピンとこない思うので、Flattened型を利用する具体例としてflattened_productsインデックスを考えてみます。
このインデックスでは商品情報を管理し、attributes項目に各商品ごとの仕様やスペック情報を格納したいとします。
とはいえ、格納したいattributesは商品ごとにあるのでマッピングの数が膨大になってしまうことが予想されます。
ここで、attributesにFlattenedを使用します。
PUT flattened_products
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "text"
},
"price": {
"type": "integer"
},
"attributes": {
"type": "flattened"
}
}
}
}
ドキュメントを格納します。
POST /products/_doc/1
{
"product_id": "12345",
"product_name": "iPhone 13",
"price": 120000,
"attributes": {
"screen_size": "6.1 inches",
"display_type": "Super Retina XDR",
"camera": "Dual 12MP cameras with Night mode",
"battery": "Built-in rechargeable lithium-ion battery"
}
}
この時、attributesは内部的にこのようにドキュメントが格納されています。
{
"attributes": [
"screen_size\u00006.1 inches",
"display_type\u0000Super Retina XDR",
"camera\u0000Dual 12MP cameras with Night mode",
"battery\u0000Built-in rechargeable lithium-ion battery"
]
}
Null文字(\u0000)を区切り文字として、Objectの項目のキーとバリューが一つの文字列として配列に格納されているのが分かります。
そのため、attributesに格納される項目がどれだけ増えても内部的にはマッピングが増えているわけでは無いのでマッピング爆発は起こりません。
ちなみにFlattenedはObjectと同様にフィールド間の関係性が保持されませんので、保持したい場合はNestedを使用してください。
親子関係を構築するデータ型
階層構造を内部に持つのではなく、ドキュメント間に親子関係を持たせるデータ型としてJoinがあります。
Join
Joinを使用すると同一インデックス内のドキュメント間に親子関係を持たせることができます。
試しにJoinを用いたマッピングを作成します。
PUT join_test
{
"mappings": {
"properties": {
"body_text":{
"type": "text"
},
"doc_type": {
"type": "join",
"relations": {
"post": "reply"
}
}
}
}
}
relationsに 親 : 子 の順でドキュメントの関係性を定義します。ここではpostはreplyの親です。
次に親ドキュメントを追加します。
PUT join_test/_doc/1
{
"body_text": "This is a post",
"doc_type": {
"name": "post"
}
}
doc_typeのところは省略形で下記のようにも書けます。
"doc_type": "post"
子ドキュメントを2つ追加します。
PUT join_test/_doc/2?routing=1
{
"body_text": "This is a reply1",
"doc_type": {
"name": "reply",
"parent": "1"
}
}
PUT join_test/_doc/3?routing=1
{
"body_text": "This is a reply2",
"doc_type": {
"name": "reply",
"parent": "1"
}
}
親と子のドキュメントは同一シャードに格納される必要があるため、クエリパラメータ routingにも親ドキュメントのIDを設定します。
また、doc_typeには子ドキュメントであることを示すreplyを設定するとともに親ドキュメントのIDをparentに設定します。
では検索してみます。
has_childクエリを用いるとJoin項目を持つ子ドキュメントが指定されたクエリに一致する場合にそれの親ドキュメントを返します。
GET join_test/_search
{
"query": {
"has_child": {
"type": "reply",
"query": {
"match": {
"body_text": "reply1"
}
}
}
}
}
ID 2の子ドキュメントには"reply1"という文字列が含まれているのでID 1の親ドキュメントが返却されます。
{
"took": 1,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 1,
"hits": [
{
"_index": "join_test",
"_id": "1",
"_score": 1,
"_source": {
"body_text": "This is a post",
"doc_type": {
"name": "post"
}
}
}
]
}
}
逆に、指定したクエリにヒットする親ドキュメントが持つ子ドキュメントを返却するhas_parentクエリもあります。
GET join_test/_search
{
"query": {
"has_parent": {
"parent_type": "post",
"query": {
"match": {
"body_text": "post"
}
}
}
}
}
文字列"post"をbody_text項目に持つ親ドキュメントの子ドキュメントが全て返却されます。
{
"took": 2,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 2,
"relation": "eq"
},
"max_score": 1,
"hits": [
{
"_index": "join_test",
"_id": "2",
"_score": 1,
"_routing": "1",
"_source": {
"body_text": "This is a reply1",
"doc_type": {
"name": "reply",
"parent": "1"
}
}
},
{
"_index": "join_test",
"_id": "3",
"_score": 1,
"_routing": "1",
"_source": {
"body_text": "This is a reply2",
"doc_type": {
"name": "reply",
"parent": "1"
}
}
}
]
}
}
ご覧のように親子関係を1つのインデックス内に定義できました。
ただし、Joinには注意点も多いです。
- 1つのインデックスにつき、Join型の項目は1つまでしか作成できない
- 親ドキュメントと子ドキュメントは同じシャードでインデックスを作成する必要がある
- 子ドキュメントの取得、削除、更新の際に、同じルーティング値を設定する必要がある
- あるドキュメントは複数の子を持つことができるが、親は1つだけ
- Joinはクエリ時にメモリと計算の面でオーバーヘッドがかかるため、検索性能を向上させるためには基本的にデータを非正規化したほうがよい
つまり、本当に必要で無い限り極力使わない方がパフォーマンスのためにはいいよということですね。
では、どのようなときに使うべきか、公式の和訳を載せます。
Joinフィールドが意味を持つ唯一のケースは、データに1対多の関係があり、一方のエンティティが他方のエンティティを著しく上回っている場合です。このようなケースの例としては、商品とその商品に対するオファーがあるユースケースがあります。オファーが商品の数を大きく上回っている場合、商品を親ドキュメント、オファーを子ドキュメントとしてモデル化することが理にかなっています。
上記の条件に加え個人的には親子ともにドキュメントとして扱うことにメリットがあるときに使用を検討すべきかと思います
例えば、TwitterのようなSNSアプリを運用していて、投稿と回答の通報状況を一覧リストで確認する通報管理画面が欲しいとします。
ここで、画面で投稿も回答も同じように扱いたいとする要件があったとき、投稿ドキュメントが階層構造で回答を保持していると少し面倒くさいです。クエリではドキュメントとしてしかデータを取得できないため、ある投稿に通報済み回答がいくつ含まれているかは実際にデータを取得してからクライアント側で中身を覗いてみるまで分かりません。
また、クエリの投げ方にもよりますが、投稿と回答を同時に一度に取得したいとすると、投稿と回答どちらが通報されているのか、あるいは両方通報されているのか判断するのに更に手間がかかります。
一方、Joinであれば親も子も関係なく通報されているドキュメントを抜き出せばよいだけです。
(通報機能のためだけにJoinを使うかは議論の余地がありますが、あくまでも例ということで)
まとめると、Joinでリレーションを張れるのは魅力的ですがきちんと制約については把握しておくことが必要です。また、利用に際してはパフォーマンス的に問題無いかできれば事前検証しておくべきだと思います。
次回は検索やデータ登録時に利用する転置インデックスを作成するためのアナライザーという設定について解説します。