Elasticsearch で検索条件に合致した nested の要素だけを抽出

Elasticsearch の nested を用いた検索において、inner_hits を使って検索条件に合致した nested の要素だけを抽出するようにしてみました。

今回のソースは http://github.com/fits/try_samples/tree/master/blog/20211011/

はじめに

Elasticsearch では nested という型を用いる事で、入れ子構造を実現できるようになっています。

ここでは、商品毎に複数のエディションがあって、エディション毎にセールの履歴を保持しているような、2層の nested を持つサンプルデータを用意してみました。

Elasticsearch のマッピング設定は以下。

マッピング設定
$ curl -s http://localhost:9200/items/_mappings | jq
{
  "items": {
    "mappings": {
      "properties": {
        "editions": {
          "type": "nested",
          "properties": {
            "edition": {
              "type": "keyword"
            },
            "price": {
              "type": "scaled_float",
              "scaling_factor": 100
            },
            "sales": {
              "type": "nested",
              "properties": {
                "date_from": {
                  "type": "date"
                },
                "date_to": {
                  "type": "date"
                },
                "sale_price": {
                  "type": "scaled_float",
                  "scaling_factor": 100
                }
              }
            }
          }
        },
        "id": {
          "type": "keyword"
        },
        "name": {
          "type": "text"
        }
      }
    }
  }
}

ドキュメントの内容は以下の通りです。

全ドキュメント内容
$ curl -s http://localhost:9200/items/_search | jq
{
  ・・・
  "hits": {
    ・・・
    "hits": [
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-001",
        "_score": 1,
        "_source": {
          "id": "id-001",
          "name": "item-A",
          "editions": [
            {
              "edition": "Standard",
              "price": 1000,
              "sales": [
                {
                  "sale_price": 900,
                  "date_from": "2021-01-01T00:00:00Z",
                  "date_to": "2021-01-03T00:00:00Z"
                },
                {
                  "sale_price": 800,
                  "date_from": "2021-07-01T00:00:00Z",
                  "date_to": "2021-07-05T00:00:00Z"
                }
              ]
            },
            {
              "edition": "Extra",
              "price": 2000,
              "sales": [
                {
                  "sale_price": 1800,
                  "date_from": "2021-01-01T00:00:00Z",
                  "date_to": "2021-01-03T00:00:00Z"
                },
                {
                  "sale_price": 1700,
                  "date_from": "2021-07-01T00:00:00Z",
                  "date_to": "2021-07-05T00:00:00Z"
                },
                {
                  "sale_price": 1500,
                  "date_from": "2021-09-01T00:00:00Z",
                  "date_to": "2021-09-02T00:00:00Z"
                }
              ]
            }
          ]
        }
      },
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-002",
        "_score": 1,
        "_source": {
          "id": "id-002",
          "name": "item-B",
          "editions": [
            {
              "edition": "Standard",
              "price": 1500,
              "sales": [
                {
                  "sale_price": 1400,
                  "date_from": "2021-09-01T00:00:00Z",
                  "date_to": "2021-09-05T00:00:00Z"
                }
              ]
            },
            {
              "edition": "Extra",
              "price": 5000,
              "sales": []
            }
          ]
        }
      },
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-003",
        "_score": 1,
        "_source": {
          "id": "id-003",
          "name": "item-C",
          "editions": [
            {
              "edition": "Standard",
              "price": 7000,
              "sales": [
                {
                  "sale_price": 6800,
                  "date_from": "2021-01-01T00:00:00Z",
                  "date_to": "2021-01-03T00:00:00Z"
                },
                {
                  "sale_price": 6700,
                  "date_from": "2021-02-01T00:00:00Z",
                  "date_to": "2021-02-05T00:00:00Z"
                },
                {
                  "sale_price": 6600,
                  "date_from": "2021-04-01T00:00:00Z",
                  "date_to": "2021-04-02T00:00:00Z"
                },
                {
                  "sale_price": 6500,
                  "date_from": "2021-07-01T00:00:00Z",
                  "date_to": "2021-07-15T00:00:00Z"
                }
              ]
            }
          ]
        }
      },
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-004",
        "_score": 1,
        "_source": {
          "id": "id-004",
          "name": "item-D",
          "editions": [
            {
              "edition": "Standard",
              "price": 4000,
              "sales": []
            },
            {
              "edition": "Extra",
              "price": 6000,
              "sales": []
            },
            {
              "edition": "Premium",
              "price": 9000,
              "sales": []
            }
          ]
        }
      }
    ]
  }
}

検索

Deno 用の TypeScript で検索処理を実装してみます。

Deno 用の公式 Elasticsearch クライアントライブラリは今のところ無さそうなので、fetch を使って REST API を呼び出すような関数を用意してみました。

es_util.ts
// Elasticsearch の REST API 呼び出し
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
    }
}

ここでは、下記のような条件で検索する事にします。

  • 2021/7/1 以降のセール価格が 1500 以下だったもの

1. inner_hits を用いない検索

まずは、inner_hits を用いず普通に検索します。

nested のフィールドを検索条件として指定するために、Nested query({ nested: path: ・・・, query: ・・・ })を使用します。

editions 内の sales を検索条件として指定するために、Nested query を入れ子にします。

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

const index = 'items'
const baseUrl = 'http://localhost:9200'

const indexUrl = `${baseUrl}/${index}`

const { body } = await send(`${indexUrl}/_search`, 'POST', {
  query: {
    nested: {
      path: 'editions',
      query: {
        nested: {
          path: 'editions.sales',
          query: {
            bool: {
              must: [
                {
                  range: {
                    'editions.sales.date_from': { gte: '2021-07-01T00:00:00Z' }
                  }
                },
                {
                  range: {
                    'editions.sales.sale_price': { lte: 1500 }
                  }
                }
              ]
            }
          }
        }
      }
    }
  }
})

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

実行結果は次の通り、_source は元のドキュメント内容そのままなので、どのエディションの、どのセール履歴が検索条件に合致したかは分かりません。

実行結果
> deno run --allow-net search1.ts

{
  ・・・
  "hits": {
    ・・・
    "hits": [
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-001",
        "_score": 2,
        "_source": {
          "id": "id-001",
          "name": "item-A",
          "editions": [
            {
              "edition": "Standard",
              "price": 1000,
              "sales": [
                {
                  "sale_price": 900,
                  "date_from": "2021-01-01T00:00:00Z",
                  "date_to": "2021-01-03T00:00:00Z"
                },
                {
                  "sale_price": 800,
                  "date_from": "2021-07-01T00:00:00Z",
                  "date_to": "2021-07-05T00:00:00Z"
                }
              ]
            },
            {
              "edition": "Extra",
              "price": 2000,
              "sales": [
                {
                  "sale_price": 1800,
                  "date_from": "2021-01-01T00:00:00Z",
                  "date_to": "2021-01-03T00:00:00Z"
                },
                {
                  "sale_price": 1700,
                  "date_from": "2021-07-01T00:00:00Z",
                  "date_to": "2021-07-05T00:00:00Z"
                },
                {
                  "sale_price": 1500,
                  "date_from": "2021-09-01T00:00:00Z",
                  "date_to": "2021-09-02T00:00:00Z"
                }
              ]
            }
          ]
        }
      },
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-002",
        "_score": 2,
        "_source": {
          "id": "id-002",
          "name": "item-B",
          "editions": [
            {
              "edition": "Standard",
              "price": 1500,
              "sales": [
                {
                  "sale_price": 1400,
                  "date_from": "2021-09-01T00:00:00Z",
                  "date_to": "2021-09-05T00:00:00Z"
                }
              ]
            },
            {
              "edition": "Extra",
              "price": 5000,
              "sales": []
            }
          ]
        }
      }
    ]
  }
}

2. inner_hits を用いた検索

それでは、本題の inner_hits を使った検索を行います。

inner_hits は Nested query で指定できます。 inner_hits を指定した場合は _source とは別の inner_hits という項目に該当結果が設定されます。

ここでは、inner_hits で取得する内容は Source filtering を使って、元の _source から取り除くように設定しています。(Source filtering は inner_hits 内でも設定可)

また、_sourceinner_hits の結果を結合して、検索条件に合致した要素だけで構成されるドキュメントを構築する処理(toDoc)も用意してみました。

search2.ts
・・・
const { body } = await send(`${indexUrl}/_search`, 'POST', {
  _source: {
    // _source から editions の内容を取り除く
    excludes: ['editions']
  },
  query: {
    nested: {
      path: 'editions',
      query: {
        nested: {
          path: 'editions.sales',
          query: {
            bool: {
              must: [
                {
                  range: {
                    'editions.sales.date_from': { gte: '2021-07-01T00:00:00Z' }
                  }
                },
                {
                  range: {
                    'editions.sales.sale_price': { lte: 1500 }
                  }
                }
              ]
            }
          },
          // editions.sales に対する inner_hits の設定
          inner_hits: {}
        }
      },
      // editions に対する inner_hits の設定
      inner_hits: {
        _source: {
          // inner_hits の _source から sales の部分を取り除く
          excludes: ['editions.sales']
        }
      }
    }
  }
})

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

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

// _source の内容に inner_hits の内容を再帰的に結合する処理
const toDoc = (res: any) => res.hits.hits.map((r: any) => {
  const doc = { ...r._source }

  for (const [k, v] of Object.entries(r.inner_hits ?? {})) {
    const key = k.split('.').slice(-1)[0]
    doc[key] = toDoc(v)
  }

  return doc
})

const res = toDoc(body)

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

実行結果は次のようになりました。

実行結果
> deno run --allow-net search2.ts 

{
  ・・・
  "hits": {
    ・・・
    "hits": [
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-001",
        "_score": 2,
        "_source": {
          "name": "item-A",
          "id": "id-001"
        },
        "inner_hits": {
          "editions": {
            "hits": {
              ・・・
              "hits": [
                {
                  "_index": "items",
                  "_type": "_doc",
                  "_id": "id-001",
                  "_nested": {
                    "field": "editions",
                    "offset": 0
                  },
                  "_score": 2,
                  "_source": {
                    "price": 1000,
                    "edition": "Standard"
                  },
                  "inner_hits": {
                    "editions.sales": {
                      "hits": {
                        ・・・
                        "hits": [
                          {
                            "_index": "items",
                            "_type": "_doc",
                            "_id": "id-001",
                            "_nested": {
                              "field": "editions",
                              "offset": 0,
                              "_nested": {
                                "field": "sales",
                                "offset": 1
                              }
                            },
                            "_score": 2,
                            "_source": {
                              "date_to": "2021-07-05T00:00:00Z",
                              "sale_price": 800,
                              "date_from": "2021-07-01T00:00:00Z"
                            }
                          }
                        ]
                      }
                    }
                  }
                },
                {
                  "_index": "items",
                  "_type": "_doc",
                  "_id": "id-001",
                  "_nested": {
                    "field": "editions",
                    "offset": 1
                  },
                  "_score": 2,
                  "_source": {
                    "price": 2000,
                    "edition": "Extra"
                  },
                  "inner_hits": {
                    "editions.sales": {
                      "hits": {
                        ・・・
                        "hits": [
                          {
                            "_index": "items",
                            "_type": "_doc",
                            "_id": "id-001",
                            "_nested": {
                              "field": "editions",
                              "offset": 1,
                              "_nested": {
                                "field": "sales",
                                "offset": 2
                              }
                            },
                            "_score": 2,
                            "_source": {
                              "date_to": "2021-09-02T00:00:00Z",
                              "sale_price": 1500,
                              "date_from": "2021-09-01T00:00:00Z"
                            }
                          }
                        ]
                      }
                    }
                  }
                }
              ]
            }
          }
        }
      },
      ・・・
    ]
  }
}
-----
[
  {
    "name": "item-A",
    "id": "id-001",
    "editions": [
      {
        "price": 1000,
        "edition": "Standard",
        "sales": [
          {
            "date_to": "2021-07-05T00:00:00Z",
            "sale_price": 800,
            "date_from": "2021-07-01T00:00:00Z"
          }
        ]
      },
      {
        "price": 2000,
        "edition": "Extra",
        "sales": [
          {
            "date_to": "2021-09-02T00:00:00Z",
            "sale_price": 1500,
            "date_from": "2021-09-01T00:00:00Z"
          }
        ]
      }
    ]
  },
  {
    "name": "item-B",
    "id": "id-002",
    "editions": [
      {
        "price": 1500,
        "edition": "Standard",
        "sales": [
          {
            "date_to": "2021-09-05T00:00:00Z",
            "sale_price": 1400,
            "date_from": "2021-09-01T00:00:00Z"
          }
        ]
      }
    ]
  }
]