Elasticsearch で nested の集計
Elasticsearch において 2層の nested 型フィールドで集計してみました。
サンプルのソースコードは http://github.com/fits/try_samples/tree/master/blog/20211127/
はじめに
下記のような 2層のカテゴリ(categories
と children
は 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 直下の categories
、count_items
、children
の箇所では任意の名前を使えます。
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 } ] } ] }