Elasticsearch 入門。nested フィールドについて

2022.06.04

Elasticsearch初学者の中村です。 間が空いてしまったのですが、今回は Elasticsearch のフィールドタイプ nested フィールドについて書いていきます。

nested フィールドとは

nested フィールドは Elasticsearch でマッピングに指定できる型の1つで、オブジェクト配列をお互いに独立して検索できる様インデックス化するためのフィールドです。

説明文だけを見ても分かりにくいですが、入れ子になったJSONオブジェクトに対し検索を可能にするフィールドになります。

そんなの必要なの?と思うかもしれませんが、Elasticsearch の仕様や使い方をまとめてみます。

nested フィールドが何故必要なのか

Elasticsearch では入れ子になったオブジェクトは通常取り扱えません。これは Lucene でも同様です。 その為、入れ子になったオブジェクトはフィールド名と値がリストにフラット化されて保存されます。 どういう事なのか、実際に試してみましょう。

下記のようなドキュメントを登録します。これは fans インデックスにユーザー情報 Yamada IchiroTanaka Jiro を登録しています。

PUT fans/_doc/1
{
  "users": [
    {
      "first_name": "Yamada",
      "last_name": "Ichiro"
    },
    {
      "first_name": "Tanaka",
      "last_name": "Jiro"
    }
  ]
}

では、この fans インデックスに Yamada Jiro という存在しないユーザー情報があるか検索を実行してみましょう。

POST fans/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "users.first_name": "Yamada"
          }
        },
        {
          "match": {
            "users.last_name": "Jiro"
          }
        }
      ]
    }
  }
}

Yamada Jiro というユーザー情報は登録されていないのに、ドキュメントがヒットしてしまいました。

...
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.5753642,
    "hits" : [
      {
        "_index" : "fans",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.5753642,
        "_source" : {
          "users" : [
            {
              "first_name" : "Yamada",
              "last_name" : "Ichiro"
            },
            {
              "first_name" : "Tanaka",
              "last_name" : "Jiro"
            }
          ]
        }
      }

この原因を調べるには以下の検索クエリを実行して、 Elasticsearch 内部で内部オブジェクトがどのように扱われているかを見る必要があります。 docvalue_fields について別の機会に説明予定です。

POST fans/_search
{
  "_source": false,
  "docvalue_fields": ["users.first_name.keyword", "users.last_name.keyword"]
}

レスポンスの .hits.hits.fields 配下で実際にElasticsearch で保存されている情報を確認できます。

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "fans",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "fields" : {
          "users.first_name.keyword" : [
            "Tanaka",
            "Yamada"
          ],
          "users.last_name.keyword" : [
            "Ichiro",
            "Jiro"
          ]
        }
      }
    ]
  }
}
  • users.first_name.keyword
    • Tanaka
    • Yamada
  • users.last_name.keyword
    • Ichiro
    • Jiro

この様に登録されているだけで、インデキシングした際の Tanaka Ichiro Yamada Jiro という単語同士の結びつきが失われています。 その為、検索結果が意図しない形になったのです。

nested フィールドを使おう

その様な場合には nested フィールドを利用しましょう。 使い方は対象フィールドのマッピングに指定して、検索時に nested クエリを利用するだけです。

マッピング

下記を実行します。 これは users フィールドが nested フィールドだとマッピングするものです。

DELETE fans
PUT fans 
{
  "mappings": {
    "properties": {
      "users" : {
        "type": "nested"
      }
    }
  }
}

検索

先ほどと同じドキュメントをインデキシングし、以下の検索クエリを実行してみましょう。

POST fans/_search
{
  "query": {
    "nested": {
      "path": "users",
      "query": {
        "bool": {
          "must": [
            {
              "match": {
                "users.first_name": "Yamada"
              }
            },
            {
              "match": {
                "users.last_name": "Taro"
              }
            }
          ]
        }
      }
    }
  }
}

今度はドキュメントがヒットしなくなりました。 登録した時に Yamada Taro は存在しないので望む結果になりました。

{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  }
}

では登録した Yamada Ichiro で検索してみるとどうなるでしょう。

POST fans/_search
{
  "query": {
    "nested": {
      "path": "users",
      "query": {
        "bool": {
          "must": [
            {
              "match": {
                "users.first_name": "Yamada"
              }
            },
            {
              "match": {
                "users.last_name": "Ichiro"
              }
            }
          ]
        }
      }
    }
  }
}

こちらはドキュメントがヒットします。

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 1.3862942,
    "hits" : [
      {
        "_index" : "fans",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.3862942,
        "_source" : {
          "users" : [
            {
              "first_name" : "Yamada",
              "last_name" : "Ichiro"
            },
            {
              "first_name" : "Tanaka",
              "last_name" : "Jiro"
            }
          ]
        }
      }
    ]
  }
}

nested クエリの使い方

検索時に nested クエリは下記のように指定します。

.nested.path には検索したい入れ子となったオブジェクトへのパスを指定します。今回の例だと users です。

.nested.query には入れ子となったオブジェクトに検索したいクエリを記述します。

上記はどちらも必須項目です。 他にも指定可能なパラメータがありますので、そちらは公式のドキュメントをご覧ください。

...
"nested": {
  "path": "users",
  "query": {
...

nestedクエリに一致したオブジェクトを調べたい

先ほどの検索結果だとドキュメントの全てが返ってきてしまい、どのオブジェクトが nested クエリに一致したのかが分かりません。 それを知りたい場合には inner_hits を指定して検索をしてみましょう。

下記は nestedクエリに、 "inner_hits": {} を追加した検索クエリになります。

POST fans/_search
{
  "query": {
    "nested": {
      "inner_hits": {}, 
      "path": "users",
      "query": {
        "bool": {
          "must": [
            {
              "match": {
                "users.first_name": "Yamada"
              }
            },
            {
              "match": {
                "users.last_name": "Ichiro"
              }
            }
          ]
        }
      }
    }
  }
}
```

レスポンスは下記になります。 .hits.hits.inner_hits._source に検索クエリで一致した Yamada Ichiro のオブジェクトが返ってきます。

{
  "took" : 15,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 1.3862942,
    "hits" : [
      {
        "_index" : "fans",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.3862942,
        "_source" : {
          "users" : [
            {
              "first_name" : "Yamada",
              "last_name" : "Ichiro"
            },
            {
              "first_name" : "Tanaka",
              "last_name" : "Jiro"
            }
          ]
        },
        "inner_hits" : {
          "users" : {
            "hits" : {
              "total" : {
                "value" : 1,
                "relation" : "eq"
              },
              "max_score" : 1.3862942,
              "hits" : [
                {
                  "_index" : "fans",
                  "_type" : "_doc",
                  "_id" : "1",
                  "_nested" : {
                    "field" : "users",
                    "offset" : 0
                  },
                  "_score" : 1.3862942,
                  "_source" : {
                    "first_name" : "Yamada",
                    "last_name" : "Ichiro"
                  }
                }
              ]
            }
          }
        }
      }
    ]
  }
}

複数レベルの入れ子

更に入れ子になったオブジェクトでも、 nested フィールドは利用が可能です。 マッピングを以下の様に入れ子毎に定義し、検索時にも nested クエリを入れ子で指定します。

PUT fans
{
  "mappings": {
    "properties": {
      "users": {
        "type": "nested",
        "properties": {
          "tags": {
            "type": "nested"
          }
        }
      }
    }
  }
}
POST fans/_search
{
  "query": {
    "nested": {
      "path": "users",
      "query": {
        "bool": {
          "must": [
            {
              "match": {
                "users.first_name": "Yamada"
              }
            },
            {
              "match": {
                "users.last_name": "Ichiro"
              }
            }
          ]
        },
        "nested": {
          "path": "tags",
          "query": {
            "match_all": {}
          }
        }
      }
    }
  }
}

注意点

nested フィールドのオブジェクトは、それぞれ Lucene のドキュメントとしてインデックスされるようです。

100個の users オブジェクトが存在する場合、親も含めて 101 個のドキュメントが作成されるらしくマッピング自体にコストが掛かってきます。

nested クエリ自体にもコストが掛かるため、検索する必要のないフィールドを nested フィールドにするのは避け、単純なドキュメント内容であればフラット化してインデキシングする方法も検討してみてください。

制限

上述したように nested はコストが高いため、Elasticsearch ではデフォルトでいくつか制限されています。

  • index.mapping.nested_fields.limit
    • インデックスのマッピングに含まれる nested フィールドの最大数です。不必要にマッピングされないよう、デフォルト 50 で設定されています
  • index.mapping.nested_objects.limit
    • 1 ドキュメントにふくまれる、入れ子オブジェクトの最大数です。オブジェクト数が多いとメモリが不足しOOMが発生する可能性があります。これはデフォルト 10000 が設定されています。

最後に

今回は nested 周りについて勉強してみました。 実際にアプリケーション構築時には利用する頻度が多そうですが、使い方やコストも考慮して設計する必要があることがわかりました。

次回は join フィールドについてまとめようと思います。