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;
};
注意点
- Nested型のコスト: Nested 型は内部的には別ドキュメントとしてインデックスされるため、更新コストやストレージ容量が通常のフィールドより大きくなります。配列の要素数が非常に多い場合は注意が必要です。
-
ページネーション:
search_afterを使用したページネーションを行う場合、ソートキーの値(この場合は日時)とドキュメントID(タイブレーカー)を次回のクエリに渡す必要があります。
特に Nested Sort の場合、search_afterに渡す値も「そのドキュメントの Nested フィールド内の該当する値」である必要があります。単純にドキュメントのルートにあるフィールドではないため、取得したヒットから値を抽出するロジックに注意してください。
おわりに
Nested 型を使いこなすことで、RDB のような正規化されたデータ構造を OpenSearch 上で表現しつつ、柔軟な検索とソートが可能になります。
特に「ユーザーごとのパーソナライズされた一覧」を作る際には、この Nested Sort のテクニックが必須となります。
参考資料
使い倒せ、テクノロジー。(MAX OUT TECHNOLOGY)をミッションに掲げる、株式会社リバネスナレッジのチャレンジを共有するブログです。Buld in Publichの精神でオープンに綴ります。 Qiita:qiita.com/organizations/leaveanest
Discussion