親子関係のあるデータの検索を、ElasticsearchのJoin field typeを使って実現した
はじめに
私が現在開発中のアプリケーションでは、データの検索をElasticsearchを用いて実現しています。
扱う検索データには色々ありますが、今回は少し特殊な検索要件を満たす必要がありました。親子関係のあるデータにおいて、検索対象は子データに限定し、結果は子データに紐づく親データのみがほしいといったものです。SQLならばJOINを使えば簡単だろうと思いましたが、Elasticsearchでどう実現しようか少し悩みました。
そして、ElasticsearchのJoin field type
を用いて親子関係のあるデータの検索をうまく実現することができたので、今回はそれについて紹介したいと思います。
どんな検索をしたかったのか
開発中のアプリケーションは注文管理システムです。ECサイトで販売中の商品が売れるとシステムに注文データが作成されます。そして、効率化のために住所が同じ注文者の注文はまとめてしまい、一括発送できるようにする機能があります。まとめ上げの結果発生した親注文を管理するだけでOKにできるというものです。
そのため、以下のような構成の注文データが発生することがあります。
「親注文」の配下に複数の「子注文」を有しており、そこには「親子関係」があります。もともとあった注文が1つ1つ「子注文」となり、子注文らを一括管理するための注文が「親注文」です。
そして、以下のように、「検索は子注文に対して行い、結果は紐づく親注文だけがほしい」という要件がありました。
これは、リレーショナルデータベースであればJOINを用いることで容易に実現することができます。
例えば、次のような関係のテーブルであれば、
イメージとして、以下のようなSQLで要件を満たせるはずです。
SELECT 親注文.*
FROM 親注文
JOIN 子注文 ON 親注文.ID = 子注文.親注文ID
WHERE 子注文.注文者 = 'テスト太郎';
しかし、私が開発しているアプリケーションでは、それをElasticsearchで実現しなければなりませんでした。親注文となるドキュメントにNested field typeで子注文の情報をすべて持たせ、要件を満たすことも考えましたが、今回はElasticsearchのJoin field type
を使うことで要件を満たしました。
Join field type
とは
Elasticsearchの公式ドキュメントは以下です。
ElasticsearchのJoin field type
は、同じインデックスの中に属するドキュメント同士に親子の関係性をもたせることができる機能です。
並列関係にある各ドキュメントに対して、
- 親ドキュメントであるか
- 子ドキュメントであるか
- どの親ドキュメントに所属するか
の情報を持たせることができます。親子関係があるデータを別々のドキュメントとして保持しつつも、親ドキュメントと子ドキュメントが「親ドキュメントID」をキーに関連付けられます。そして、検索時には親子関係を考慮したクエリを使うことで、親子関係に基づく検索が可能です。
また、ドキュメントによってはJoin field type
を使わずに、親子関係の情報を付与しないことも選択可能です。
注文の例を用いてイメージにしたものが以下です。
Join field type
を使って親子関係を考慮した検索ができるようにするためには、以下の作業が必要です。
- マッピングで親子関係のメタデータを定義
- 各ドキュメント保存時に、親であるか子であるかの情報も付与
- 親子を考慮した検索クエリを組み立てて検索する
実際に試してみたいと思います。
実際に試してみる
以下の関係性をJoin field type
を用いてElasticsearch上に表現し、それを検索する方針で進めます。Kibana経由で行います。
各注文には以下の情報を持たせます。
- 注文ID
- 注文名
- 注文者名
マッピングで親子関係のメタデータを定義
まずはマッピングを定義し、親ドキュメントと子ドキュメントの関係性を構築します。
PUT order
{
"mappings": {
"properties": {
"id": { // 注文ID
"type": "long"
},
"name": { // 注文名
"type": "keyword"
},
"orderer": { // 注文者名
"type": "keyword"
},
"join_metadata": {
"type": "join",
"relations": {
"parent_order": "child_order"
}
}
}
}
}
Join field type
の設定は、以下で行なっています。
// ... 省略
"join_metadata": {
"type": "join",
"relations": {
"parent_order": "child_order"
}
}
// ... 省略
join_metadata
の部分は親子関係の識別子のようなものであり、好きな名前をつけられます。そして、ドキュメント保存時にparent_order
として保存すればそのドキュメントが親ドキュメントに、child_order
として保存すればそのドキュメントが子ドキュメントになるように設定しています。
各ドキュメント保存時に、親であるか子であるかの情報も付与
ドキュメントを保存します。次のデータをElasticsearchに登録します。
注文ID | 注文名 | 注文者名 | 所属先の親注文名 |
---|---|---|---|
1 | 親注文A | テスト太郎 | - |
2 | 親注文B | テスト花子 | - |
3 | 子注文A | テスト太郎 | 親注文A |
4 | 子注文B | テスト次郎 | 親注文A |
5 | 子注文C | テスト三郎 | 親注文A |
6 | 子注文D | テスト花子 | 親注文B |
7 | 子注文E | テスト靖子 | 親注文B |
8 | 子注文F | テスト華子 | 親注文B |
入力するクエリは親ドキュメントの登録ならば、以下のようなスタイルとなります。
PUT order/_doc/1?routing=order
{
"id": 1,
"name": "親注文A",
"orderer": "テスト太郎",
"join_metadata": {
"name": "parent_order"
}
}
子ドキュメントの登録ならば、以下のようなスタイルとなります。
PUT order/_doc/3?routing=order
{
"id": 3,
"name": "子注文A",
"orderer": "テスト太郎",
"join_metadata": {
"name": "child_order",
"parent": "1" // 親注文AのID
}
}
マッピングで設定したjoin_metadata
フィールドで親子情報を入力しています。name
で「親」名もしくは「子」名の指定が必要となります。さらに、子ドキュメントでは所属先親ドキュメントのIDを指定することが必要です。
実際に入力したクエリ
PUT order/_doc/1?routing=order
{
"id": 1,
"name": "親注文A",
"orderer": "テスト太郎",
"join_metadata": {
"name": "parent_order"
}
}
PUT order/_doc/2?routing=order
{
"id": 2,
"name": "親注文B",
"orderer": "テスト花子",
"join_metadata": {
"name": "parent_order"
}
}
PUT order/_doc/3?routing=order
{
"id": 3,
"name": "子注文A",
"orderer": "テスト太郎",
"join_metadata": {
"name": "child_order",
"parent": "1" // 親注文AのID
}
}
PUT order/_doc/4?routing=order
{
"id": 4,
"name": "子注文B",
"orderer": "テスト次郎",
"join_metadata": {
"name": "child_order",
"parent": "1" // 親注文AのID
}
}
PUT order/_doc/5?routing=order
{
"id": 5,
"name": "子注文C",
"orderer": "テスト三郎",
"join_metadata": {
"name": "child_order",
"parent": "1" // 親注文AのID
}
}
PUT order/_doc/6?routing=order
{
"id": 6,
"name": "子注文D",
"orderer": "テスト花子",
"join_metadata": {
"name": "child_order",
"parent": "2" // 親注文BのID
}
}
PUT order/_doc/7?routing=order
{
"id": 7,
"name": "子注文E",
"orderer": "テスト靖子",
"join_metadata": {
"name": "child_order",
"parent": "2" // 親注文BのID
}
}
PUT order/_doc/8?routing=order
{
"id": 8,
"name": "子注文F",
"orderer": "テスト華子",
"join_metadata": {
"name": "child_order",
"parent": "2" // 親注文BのID
}
}
親子関係を考慮した検索クエリを組み立てて検索する
データがElasticsearch上に入ったので、実際に検索をしてみます。
子ドキュメントを検索条件にして、紐づく親ドキュメントを取得する
子注文を検索条件にして、紐づく親注文を取得してみます。
has_childクエリを使うことで、条件にマッチする子ドキュメントを持つ親ドキュメントを取得することができます。
検索パターン1: 1つだけ親ドキュメントが返るような検索条件で検索してみる
まず、注文者名が「テスト太郎」の子注文をもつ親注文を検索してみます。期待される結果としては、「子注文A」の親注文である「親注文A」のみが返却されることです。
これを実現する検索クエリは以下となります。
// 注文者名が「テスト太郎」の子注文をもつ親注文を検索
GET /order/_search?routing=order
{
"query": {
"has_child": {
"type": "child_order",
"query": {
"bool": {
"must": [
{
"term": {
"orderer": {
"value": "テスト太郎"
}
}
}
]
}
}
}
}
}
実行すると期待通り「親注文A」が取得できました。
検索パターン2: 複数の親ドキュメントが返るような検索条件で検索してみる
条件を変えて、注文者名が「テスト三郎」or「テスト華子」である子注文を検索し、紐づく親注文を取得してみます。期待される結果としては、「子注文C」の親注文である「親注文A」と、「子注文F」の親注文である「親注文B」の両方が返却されることです。
// 注文者名が「テスト三郎」or「テスト華子」である子注文をを持つ親注文を検索
GET /order/_search?routing=order
{
"query": {
"has_child": {
"type": "child_order",
"query": {
"bool": {
"should": [
{
"term": {
"orderer": {
"value": "テスト太郎"
}
}
},
{
"term": {
"orderer": {
"value": "テスト華子"
}
}
}
]
}
}
}
}
}
実行すると期待通り「親注文A」と「親注文B」が取得できました。
検索パターン3: 子ドキュメントではヒットしない条件で検索してみる
最後に、子注文ではヒットしない条件で検索をしてみます。注文IDが「1」を検索条件に指定して実施します。これは「親注文A」の注文IDなので、検索対象には含まれないはずです。したがって、期待される結果は「何もヒットしない」ことです。
// 注文IDが「1」である子注文を持つ親注文を検索
GET /order/_search?routing=order
{
"query": {
"has_child": {
"type": "child_order",
"query": {
"bool": {
"must": [
{
"term": {
"id": {
"value": 1
}
}
}
]
}
}
}
}
}
期待通り、結果は「何もヒットしない」でした。
親ドキュメントを検索条件にして、紐づく子ドキュメントを取得する
先ほどとは逆に、親ドキュメントを検索条件にして、紐づく子ドキュメントを取得してみます。
has_parentクエリを使うことで、条件にマッチする親ドキュメントを持つ子ドキュメントを取得することができます。
検索パターン1: 1つの親ドキュメントのみがヒットするような条件で検索してみる
注文IDが「1」である親注文の配下にある子注文を全て取得してみます。注文IDが「1」なのは「親注文A」です。したがって期待する結果は、「子注文A」「子注文B」「子注文C」が返ってくることです。
// 注文IDが「1」である親注文の配下にある子注文を全て取得
GET /order/_search?routing=order
{
"query": {
"has_parent": {
"parent_type": "parent_order",
"query": {
"bool": {
"must": [
{
"term": {
"id": {
"value": 1 // 親注文AのID
}
}
}
]
}
}
}
}
}
期待どおり、「子注文A」「子注文B」「子注文C」が返ってきました。
検索パターン2: 親ドキュメントではヒットしない条件で検索してみる
注文者名が「テスト靖子」であることを検索条件に指定して実施します。子注文には確かにマッチする注文は存在しますが、親注文には存在しないです。したがって、期待される結果は「何もヒットしない」ことです。
// 注文者名が「テスト靖子」である親注文の配下にある子注文を全て取得
GET /order/_search?routing=order
{
"query": {
"has_parent": {
"parent_type": "parent_order",
"query": {
"bool": {
"must": [
{
"term": {
"orderer": {
"value": "テスト靖子"
}
}
}
]
}
}
}
}
}
期待通り、結果は「何もヒットしない」でした。
検索対象に親ドキュメントと子ドキュメントの両方を含めて検索する
ElasticsearchのJoin field type
を使う場合、親子間に直接的なネスト構造は存在せず個々のデータはドキュメントとして独立します。したがって、has_childクエリやhas_parentクエリを使わなければ親子の区別なしに検索することができます。
例えば、以下のように注文者名が「テスト太郎」であることを検索条件にし、普通に検索をすると「親注文A」と「子注文A」の両方が返ってくるはずです。
// 注文者名が「テスト太郎」であるドキュメントを親ドキュメント子ドキュメント関係なく検索
GET /order/_search?routing=order
{
"query": {
"bool": {
"should": [
{
"term": {
"orderer": {
"value": "テスト太郎"
}
}
}
]
}
}
}
実行結果は以下です。「親注文A」と「子注文A」の両方が返却されました。
おわりに
いかがでしたでしょうか? ElasticsearchのJoin field type
を使うことで、親子関係のあるデータ検索をシンプルに実現できることがおわかりいただけたかと思います。また、より複雑な検索条件やクエリの最適化を考える場合にも、この方法をベースにさらに発展させられるはずです。
もし私と似たような要件の検索をElasticsearchで行う必要がでてきた際は、今回の手法をぜひ試してみてください。
Discussion