Elasticsearch で nested の集計

Elasticsearch において 2層の nested 型フィールドで集計してみました。

サンプルのソースコードhttp://github.com/fits/try_samples/tree/master/blog/20211127/

はじめに

下記のような 2層のカテゴリ(categorieschildren は nested 型)を持つドキュメントに対して、カテゴリ単位で集計します。

ドキュメント例
{
  "name": "item-1",
  "categories": [
    {
      "code": "B",
      "name": "categoryB",
      "children": [
        {
          "code": "U",
          "name": "subcategoryBU"
        }
      ]
    }
  ]
}

まずは、下記処理でマッピング定義とドキュメントを登録しました。

Elasticsearch のデフォルトでは、日付では無い文字列を text 型のフィールドと keyword 型の keyword サブフィールドへマッピングするようになっていますが、ここでは文字列を keyword 型へマッピングするように dynamic_templates を設定しました。

init.ts

import { send } from './es_util.ts'

const index = 'sample'
const baseUrl = 'http://localhost:9200'
const indexUrl = `${baseUrl}/${index}`

const N = 20
const cts1 = ['A', 'B', 'C']
const cts2 = ['S', 'T', 'U']
// インデックスの有無を確認
const { status } = await fetch(indexUrl, { method: 'HEAD' })

if (status == 200) {
  // インデックス削除
  await fetch(indexUrl, { method: 'DELETE' })
}
// マッピング定義
const { body } = await send(indexUrl, 'PUT', {
  mappings: {
    dynamic_templates: [
      {
        // 文字列を keyword 型へ動的マッピングする設定
        string_keyword: {
          match_mapping_type: 'string',
          mapping: {
            type: 'keyword'
          }
        }
      }
    ],
    properties: {
      categories: {
        type: 'nested',
        properties: {
          children: {
            type: 'nested'
          }
        }
      }
    }
  }
})

console.log(body)

const selectCategory = (cts: Array<string>) => 
  cts[Math.round(Math.random() * (cts.length - 1))]

// ドキュメント登録
for (let i = 0; i < N; i++) {
  const ct1 = selectCategory(cts1)
  const ct2 = selectCategory(cts2)

  const { body: r } = await send(`${indexUrl}/_doc`, 'POST', {
    name: `item-${i + 1}`,
    categories: [
      {
        code: ct1,
        name: `category${ct1}`,
        children: [
          {
            code: ct2,
            name: `subcategory${ct1}${ct2}`
          }
        ]
      }
    ]
  })

  console.log(`result: ${r.result}, _id: ${r._id}`)
}
es_util.ts
export const send = async (url: string, method: string, body: any) => {
  const res = await fetch(url, {
    method,
    headers: {
        "Content-Type": "application/json"
    },
    body: JSON.stringify(body)
  })

  const resBody = await res.json()

  return {
    ok: res.ok,
    body: resBody
  }
}

Deno で実行します。

実行例
> deno run --allow-net init.ts

{ acknowledged: true, shards_acknowledged: true, index: "sample" }
result: created, _id: 632STX0BXMPSr8jZ-Q7n
・・・

(a) terms による集計

categories.code とそれに属する children.code の値でグルーピングして、該当するドキュメント数をカウントしてみます。

特定のフィールドの値でグルーピングするには terms を使用します。 ちなみに、terms.field へ text 型のフィールドを指定する事はできないようです。(※1)

また、nested 型のフィールドに対して集計する際は nested.path を設定する必要がありました。(※2)

更に、categories と children の親子関係を保持したままドキュメント数をカウントするには aggs を入れ子にする必要があります。(※3)

(※1)今回は dynamic_templates を設定しているため、
       code や name フィールドは keyword 型になっていますが、
       Elasticsearch デフォルトの動的マッピングを使用した際は、
       'categories.code.keyword' のように keyword サブフィールドの方を
       terms.field へ設定する事になります

(※2)nested.path を設定せずに、、
       terms.field を categories.code としても集計結果は 0 になった

(※3)入れ子にしなかった場合、
       親子関係を無視して categories.code と children.code の値で
       それぞれ独立したカウントになる(ソースの sample0.ts 参照)

デフォルトでは、ドキュメント数の多い順にソートされるようなので、order を指定して terms.field で指定したフィールドの値順になるようにしています。

aggs 直下の categoriescount_itemschildren の箇所では任意の名前を使えます。

sample1.ts(前半)
・・・

const { body } = await send(`${indexUrl}/_search`, 'POST', {
  size: 0,
  aggs: {
    categories: {
      nested: { path: 'categories' },
      aggs: {
        count_items: {
          terms: {
            field: 'categories.code',
            order: { _term: 'asc' }
          },
          aggs: {
            children: {
              nested: { path: 'categories.children' },
              aggs: {
                count_items: {
                  terms: {
                    field: 'categories.children.code',
                    order: { _term: 'asc' }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
})

console.log(JSON.stringify(body, null, 2))

console.log('----------')
・・・

この部分の実行結果は以下の通りです。

(a) 実行結果(前半)
> deno run --allow-net sample1.ts

{
  ・・・
  "hits": {
    "total": {
      "value": 20,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "aggregations": {
    "categories": {
      "doc_count": 20,
      "count_items": {
        "doc_count_error_upper_bound": 0,
        "sum_other_doc_count": 0,
        "buckets": [
          {
            "key": "A",
            "doc_count": 4,
            "children": {
              "doc_count": 4,
              "count_items": {
                "doc_count_error_upper_bound": 0,
                "sum_other_doc_count": 0,
                "buckets": [
                  {
                    "key": "T",
                    "doc_count": 3
                  },
                  {
                    "key": "U",
                    "doc_count": 1
                  }
                ]
              }
            }
          },
          ・・・
        ]
      }
    }
  }
}

ここから必要な内容だけを残すように加工してみます。

sample1.ts(後半)
・・・
console.log('----------')

const fieldName = (rs: any) => 
  Object.keys(rs).find(k => !['key', 'doc_count'].includes(k))

type Bucket = { key: string, doc_count: number }
// 集計結果の加工
const toDoc = (rs: any) => {
  const k1 = fieldName(rs)

  if (!k1) {
    return {}
  }

  const k2 = fieldName(rs[k1])!

  const bs = rs[k1][k2].buckets.map((b: Bucket) => 
    Object.assign(
      {
        code: b.key,
        [k2]: b.doc_count
      },
      toDoc(b)
    )
  )

  return {
    [k1]: bs
  }
}

const res = toDoc(body.aggregations)

console.log(JSON.stringify(res, null, 2))

実行結果は以下の通りです。

(a) 実行結果(後半)
> deno run --allow-net sample1.ts

・・・
----------
{
  "categories": [
    {
      "code": "A",
      "count_items": 4,
      "children": [
        {
          "code": "T",
          "count_items": 3
        },
        {
          "code": "U",
          "count_items": 1
        }
      ]
    },
    {
      "code": "B",
      "count_items": 10,
      "children": [
        {
          "code": "S",
          "count_items": 2
        },
        {
          "code": "T",
          "count_items": 4
        },
        {
          "code": "U",
          "count_items": 4
        }
      ]
    },
    {
      "code": "C",
      "count_items": 6,
      "children": [
        {
          "code": "S",
          "count_items": 2
        },
        {
          "code": "T",
          "count_items": 3
        },
        {
          "code": "U",
          "count_items": 1
        }
      ]
    }
  ]
}

(b) multi_terms による集計

terms を使った集計だと code の値しか取れなかったので、ここでは multi_terms を使って categories.name や children.name の値も同時に取得できるようにしてみます。

multi_terms.terms で複数のフィールドを指定すれば、フィールドの値を連結した値(key_as_string の値)でグルーピングするようです。

集計結果の key が配列になるので、そこから code と name の値を取り出す事が可能です。

sample2.ts
・・・

const { body } = await send(`${indexUrl}/_search`, 'POST', {
  size: 0,
  aggs: {
    categories: {
      nested: { path: 'categories' },
      aggs: {
        count_items: {
          multi_terms: {
            terms: [
              { field: 'categories.code' },
              { field: 'categories.name' }
            ],
            order: { _term: 'asc' }
          },
          aggs: {
            children: {
              nested: { path: 'categories.children' },
              aggs: {
                count_items: {
                  multi_terms: {
                    terms: [
                      { field: 'categories.children.code' },
                      { field: 'categories.children.name' }
                    ],
                    order: { _term: 'asc' }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
})

console.log(JSON.stringify(body, null, 2))

console.log('----------')

const fieldName = (rs: any) => 
  Object.keys(rs).find(k => !['key', 'doc_count', 'key_as_string'].includes(k))

type Bucket = { key: [string, string], doc_count: number }

const toDoc = (rs: any) => {
  const k1 = fieldName(rs)

  if (!k1) {
    return {}
  }

  const k2 = fieldName(rs[k1])!

  const bs = rs[k1][k2].buckets.map((b: Bucket) => 
    Object.assign(
      {
        code: b.key[0],
        name: b.key[1],
        [k2]: b.doc_count
      },
      toDoc(b)
    )
  )

  return {
    [k1]: bs
  }
}

const res = toDoc(body.aggregations)

console.log(JSON.stringify(res, null, 2))

実行結果は以下の通りです。

(b) 実行結果
> deno run --allow-net sample2.ts 
{
  ・・・
  "aggregations": {
    "categories": {
      "doc_count": 20,
      "count_items": {
        "doc_count_error_upper_bound": 0,
        "sum_other_doc_count": 0,
        "buckets": [
          {
            "key": [
              "A",
              "categoryA"
            ],
            "key_as_string": "A|categoryA",
            "doc_count": 4,
            "children": {
              "doc_count": 4,
              "count_items": {
                "doc_count_error_upper_bound": 0,
                "sum_other_doc_count": 0,
                "buckets": [
                  {
                    "key": [
                      "T",
                      "subcategoryAT"
                    ],
                    "key_as_string": "T|subcategoryAT",
                    "doc_count": 3
                  },
                  {
                    "key": [
                      "U",
                      "subcategoryAU"
                    ],
                    "key_as_string": "U|subcategoryAU",
                    "doc_count": 1
                  }
                ]
              }
            }
          },
          ・・・
        ]
      }
    }
  }
}
----------
{
  "categories": [
    {
      "code": "A",
      "name": "categoryA",
      "count_items": 4,
      "children": [
        {
          "code": "T",
          "name": "subcategoryAT",
          "count_items": 3
        },
        {
          "code": "U",
          "name": "subcategoryAU",
          "count_items": 1
        }
      ]
    },
    {
      "code": "B",
      "name": "categoryB",
      "count_items": 10,
      "children": [
        {
          "code": "S",
          "name": "subcategoryBS",
          "count_items": 2
        },
        {
          "code": "T",
          "name": "subcategoryBT",
          "count_items": 4
        },
        {
          "code": "U",
          "name": "subcategoryBU",
          "count_items": 4
        }
      ]
    },
    {
      "code": "C",
      "name": "categoryC",
      "count_items": 6,
      "children": [
        {
          "code": "S",
          "name": "subcategoryCS",
          "count_items": 2
        },
        {
          "code": "T",
          "name": "subcategoryCT",
          "count_items": 3
        },
        {
          "code": "U",
          "name": "subcategoryCU",
          "count_items": 1
        }
      ]
    }
  ]
}