ネスト構造をもつドキュメントの検索

OpenSearch はさまざまな構造のデータを登録・検索することができます。
その中には、ネスト構造を持つドキュメントもあるかと思います。
そういったドキュメントの検索方法についてみていきます。

目次

ネスト構造を持つドキュメントって?

例として、複数ページからなるテキストファイルを考えてみます。

1つのテキストファイルに、複数のページがあり、1ページ1ページにテキストが存在しています。
このテキストファイルに対して検索した際に、ヒットしたページも返したい場合、各ページのテキストは分けて持っておきたいと考えると思います。
イメージとしては、テキストファイル(親ドキュメント)が、ページ(子ドキュメント)を持っている状態です。

nested フィールド

ネストしたデータ構造を表すフィールドタイプとして、nested があります。
nestedで指定されたオブジェクトは実際には別ドキュメントして保存され、親ドキュメントは子ドキュメントへの参照を持ちます。

先ほどの例のドキュメントの mapping を nested を利用して定義してみるとこんな感じです。

{
  "settings": {
    "analysis": {
      "char_filter": {
        "normalize": {
          "type": "icu_normalizer",
          "name": "nfkc",
          "mode": "compose"
        }
      },
      "analyzer": {
        "kuromoji_analyzer": {
          "type": "custom",
          "char_filter": [
            "normalize"
          ],
          "tokenizer": "kuromoji_tokenizer",
          "filter": [
            "kuromoji_readingform"
          ]
        }
      }
    }
  },
    "mappings": {
        "properties": {
            "pages": {
                "type": "nested",  // これをつけたレイヤーの properties が子ドキュメントとなる
                "include_in_parent": true,
                "properties": {
                    "content": {
                        "type": "text",
                        "term_vector": "with_positions_offsets",
                        "analyzer": "kuromoji_analyzer"
                    },
                    "page": {
                        "type": "long"
                    }
                }
            },
            "title": {
                "type": "text",
                "term_vector": "with_positions_offsets",
                "analyzer": "kuromoji_analyzer"
            },
            "total_page_count": {
                "type": "long"
            },
            "@timestamp": {
                "type": "keyword"
            }
        }
    }
}

続いて、ドキュメントを登録します。今回も GPT さんが適当に作ってくれたデータです。

POST /documents/_bulk
{
  "index": {
    "_index": "documents",
    "_id": "1"
  }
}
{
  "title": "Document A",
  "total_page_count": 5,
  "pages": [
    {
      "page": 1,
      "content": "Document Aのページ1の内容は、日本の伝統文化についての説明です。"
    },
    {
      "page": 2,
      "content": "Document Aのページ2の内容は、宇宙の仕組みに関する探求です。"
    },
    {
      "page": 3,
      "content": "Document Aのページ3の内容は、世界各国の美しい風景写真集です。"
    },
    {
      "page": 4,
      "content": "Document Aのページ4の内容は、新技術の発展に関する最新のニュースです。"
    },
    {
      "page": 5,
      "content": "Document Aのページ5の内容は、未来の予測についての興味深い考察です。"
    }
  ],
  "@timestamp": "2024-05-06"
}
{
  "index": {
    "_index": "documents",
    "_id": "2"
  }
}
{
  "title": "Document B",
  "total_page_count": 3,
  "pages": [
    {
      "page": 1,
      "content": "Document Bのページ1の内容は、動物の生態に関する興味深い研究結果です。"
    },
    {
      "page": 2,
      "content": "Document Bのページ2の内容は、料理のレシピと料理の写真集です。"
    },
    {
      "page": 3,
      "content": "Document Bのページ3の内容は、歴史上の重要な出来事についての詳細な解説です。"
    }
  ],
  "@timestamp": "2024-05-06"
}

複数ページあるドキュメント内の検索

登録したドキュメントを検索してみます。

クエリに nested を使用して、どの nested フィールドかを path で指定します。
また、各子ドキュメントに対する source と highlight を inner_hits の中に記載します。

GET /documents/_search
{
  "query": {
    "nested": {
      "path": "pages",
      "query": {
        "match": {
          "pages.content": "写真集"
        }
      },
      "inner_hits": {
        "_source": [
          "pages.page"
        ],
        "highlight": {
          "fields": {
            "pages.content": {
              "matched_fields": [
                "pages.content"
              ],
              "type": "fvh"
            }
          }
        }
      }
    }
  },
  "_source": [
    "title",
    "total_page_count"
  ]
}

結果

{
  "took": 51,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 2.131073,
    "hits": [
      {
        "_index": "documents",
        "_id": "1",
        "_score": 2.131073,
        "_source": {
          "total_page_count": 5,
          "title": "Document A"
        },
        "inner_hits": {
          "pages": {
            "hits": {
              "total": {
                "value": 1,
                "relation": "eq"
              },
              "max_score": 2.131073,
              "hits": [
                {
                  "_index": "documents",
                  "_id": "1",
                  "_nested": {
                    "field": "pages",
                    "offset": 2
                  },
                  "_score": 2.131073,
                  "_source": {
                    "page": 3
                  },
                  "highlight": {
                    "pages.content": [
                      "Document Aのページ3の内容は、世界各国の美しい風景<em>写真集</em>です。"
                    ]
                  }
                }
              ]
            }
          }
        }
      },
      {
        "_index": "documents",
        "_id": "2",
        "_score": 2.09242,
        "_source": {
          "total_page_count": 3,
          "title": "Document B"
        },
        "inner_hits": {
          "pages": {
            "hits": {
              "total": {
                "value": 1,
                "relation": "eq"
              },
              "max_score": 2.09242,
              "hits": [
                {
                  "_index": "documents",
                  "_id": "2",
                  "_nested": {
                    "field": "pages",
                    "offset": 1
                  },
                  "_score": 2.09242,
                  "_source": {
                    "page": 2
                  },
                  "highlight": {
                    "pages.content": [
                      "Document Bのページ2の内容は、料理のレシピと料理の<em>写真集</em>です。"
                    ]
                  }
                }
              ]
            }
          }
        }
      }
    ]
  }
}

ドキュメントの中で、どのページのどの辺りがヒットしたかを得ることができました。
ページ数を利用してリンクを作成しておけば、検索にヒットしたページへダイレクトで遷移させるといったことも可能ですね。

おわりに

nested フィールドを使って、ネスト構造を持つドキュメントの検索に対応してみました。
割と使いどころはありそうな気がします。