🔧

AWS CDK で OpenSearch のインデックスに Nested 型フィールドを定義してフィルタやソートをできるようにする

に公開

はじめに

Amplify Gen2 × Amazon OpenSearch Service 実践解説 — OSISでDynamoDB連携をCDK構築 の記事では、基本的なデータ連携の構築方法について解説しました。

今回はその拡張として、取り込むデータ構造がより複雑な場合、具体的には「配列(リスト)」の中にオブジェクトを持つ Nested 型フィールドを定義し、それに対して検索(フィルタリング)やソートを行う方法について解説します。

特に、「配列内の特定の要素が条件を満たすドキュメントを検索し、その要素の値でソートしたい」という要件は、ECサイトのSKU検索や、今回のようなタスク管理システム(1つのTodoを複数のユーザーに割り当てる構造)で頻出します。

前提データ構造

以下のようなドキュメント構造を想定します。
Todo ドキュメントの中に、assignees(誰にいつ割り当てられたか)という配列が含まれています。

{
  "id": "todo-123",
  "content": "API設計",
  "assignees": [
    {
      "userId": "user-A",
      "userName": "田中太郎",
      "assignedAt": "2024-01-01T10:00:00Z",
      "status": "active"
    },
    {
      "userId": "user-B",
      "userName": "佐藤花子",
      "assignedAt": "2024-02-01T15:00:00Z",
      "status": "completed"
    }
  ]
}

この assignees フィールドは、OpenSearch のマッピングで nested 型として定義されている必要があります。

"mappings": {
  "properties": {
    "assignees": {
      "type": "nested",
      "properties": {
        "userId": { "type": "keyword" },
        "userName": { 
          "type": "text",
          "analyzer": "kuromoji",
          "fields": {
            "ngram": { "type": "text", "analyzer": "ngram" }
          }
        },
        "assignedAt": { "type": "date" },
        "status": { "type": "keyword" }
      }
    }
  }
}

課題:Nestedフィールドの検索とソート

このデータに対して、以下の要件を実現したいとします。

要件: 「ユーザーA (user-A) に割り当てられたTodo一覧を、ユーザーAへの割当日時 (assignedAt) の降順で取得したい」

単純なクエリでは、「ユーザーBへの割当日時」が混ざってしまったり、正しくソートされなかったりします。ここで nested クエリと nested ソートの出番です。

実装:Nested Query

まず、特定のユーザーが含まれているドキュメントを検索するには nested クエリを使用します。

{
  "query": {
    "nested": {
      "path": "assignees",
      "query": {
        "bool": {
          "must": [
            { "term": { "assignees.userId": "user-A" } }
          ]
        }
      }
    }
  }
}

path に配列フィールド名を指定し、その中の query で条件を指定します。これで「user-A を含む配列要素を持つドキュメント」がヒットします。

実装:Nested Sort

次に、検索結果を「user-A の assignedAt」でソートします。
ここが重要なポイントですが、単に assignees.assignedAt でソートすると、OpenSearch は配列内の最小値や最大値(デフォルトや mode 指定による)を使ってソートしてしまいます。

失敗例(通常のソート):
user-A への割当日が 2024-01-01 でも、同じTodoに user-B が 2024-12-31 に割り当てられていると、OpenSearch は「そのドキュメント内の最大の日付(2024-12-31)」をソートキーとして採用してしまい、意図しない順序になります。

「user-A の要素の値」だけを使ってソートするには、ソート定義内にも nested フィルタを記述する必要があります。

{
  "sort": [
    {
      "assignees.assignedAt": {
        "order": "desc",
        "nested": {
          "path": "assignees",
          "filter": {
            "term": { "assignees.userId": "user-A" }
          }
        }
      }
    }
  ]
}

このように nested ブロック内で filter を指定することで、「userId が user-A である要素の assignedAt」のみがソートの基準として採用されます。

TypeScript (Amplify Gen2) での実装例

Amplify Gen2 の API (AppSync) から Lambda リゾルバー経由などで OpenSearch クライアント (@opensearch-project/opensearch) を使用する場合のコード例です。

import { Client } from '@opensearch-project/opensearch';

const client = new Client({
  node: 'https://search-domain.ap-northeast-1.es.amazonaws.com'
});

const searchByUserId = async (userId: string) => {
  const body = {
    query: {
      nested: {
        path: "assignees",
        query: {
          term: { "assignees.userId": userId }
        }
      }
    },
    sort: [
      {
        "assignees.assignedAt": {
          "order": "desc",
          "nested": {
            "path": "assignees",
            "filter": {
              "term": { "assignees.userId": userId }
            }
          }
        }
      }
    ]
  };

  const response = await client.search({
    index: 'todo',
    body: body
  });
  
  return response.body.hits.hits;
};

注意点

  1. Nested型のコスト: Nested 型は内部的には別ドキュメントとしてインデックスされるため、更新コストやストレージ容量が通常のフィールドより大きくなります。配列の要素数が非常に多い場合は注意が必要です。
  2. ページネーション: search_after を使用したページネーションを行う場合、ソートキーの値(この場合は日時)とドキュメントID(タイブレーカー)を次回のクエリに渡す必要があります。
    特に Nested Sort の場合、search_after に渡す値も「そのドキュメントの Nested フィールド内の該当する値」である必要があります。単純にドキュメントのルートにあるフィールドではないため、取得したヒットから値を抽出するロジックに注意してください。

おわりに

Nested 型を使いこなすことで、RDB のような正規化されたデータ構造を OpenSearch 上で表現しつつ、柔軟な検索とソートが可能になります。
特に「ユーザーごとのパーソナライズされた一覧」を作る際には、この Nested Sort のテクニックが必須となります。

参考資料

リバナレテックブログ

Discussion