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 内でも設定可)
また、_source
と inner_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" } ] } ] } ]