【AWS DynamoDB】OpenSearchを使わずにキーワード検索を実現する方法 - GSIとFilterExpressionの活用術

※この記事は生成AI(Claude Code)によって作成されています。

はじめに

AWS DynamoDBでキーワード検索機能を実装する際、多くの開発者がAmazon OpenSearch Service(旧Elasticsearch Service)の導入を検討します。しかし、OpenSearchは強力な検索機能を提供する一方で、月額コストが数万円〜数十万円になることも珍しくありません。

本記事では、OpenSearchを使用せずにDynamoDB単体で事前構築したキーワードインデックスによる完全一致検索を実現する方法を、実際のプロジェクト(フィギュア写真共有サービス)での実装経験をもとに解説します。

※ 本記事の検索機能の制約
本記事で紹介する方法は、事前に作成したキーワードインデックスに対する完全一致検索です。形態素解析、曖昧検索(typo許容)、関連度スコアリングなどの機能は含まれません。これらが必要な場合はOpenSearchの導入を検討してください。

OpenSearchを使わない理由

コスト比較

サービス最小構成の月額目安本番推奨構成の月額目安
Amazon OpenSearch Service約$50〜100/月約$200〜500/月(マルチAZ)
DynamoDB(オンデマンド)使用量に応じて課金数$〜数十$/月(中規模サービス)

特にスタートアップや個人開発では、この差は大きな負担となります。

DynamoDBで検索を実現するアーキテクチャ

1. GSI(Global Secondary Index)の活用

DynamoDBでは、GSIを使用することで様々な検索パターンに対応できます。

// GSI設計例(擬似コード - 実際のJSONではコメント不可)
{
  "GSI2-CategorySearch": {
    "PK": "GSI2PK",  // CATEGORY#cat_1
    "SK": "GSI2SK",  // 作成日時(降順ソート用)
    "ProjectionType": "ALL"  // FilterExpressionでData属性を使用するため
  },
  "GSI3-LicenseFilter": {
    "PK": "GSI3PK",  // LICENSE#cc-by
    "SK": "GSI3SK"
  },
  "GSI4-SearchSupport": {
    "PK": "GSI4PK",  // SEARCH#keyword
    "SK": "GSI4SK"   // EntityType#EntityId
  },
  "GSI5-PopularitySort": {
    "PK": "GSI5PK",  // POPULAR#2026-01
    "SK": "GSI5SK"   // スコア(人気順ソート)
  }
}

2. 検索インデックスの事前構築

キーワード検索を実現するために、データ登録時に検索用インデックスを構築します。

// TypeScript (AWS SDK v3): 写真登録時にキーワードインデックスを作成
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

const keywords = extractKeywords(photo.title + ' ' + photo.description);

for (const keyword of keywords) {
  await docClient.send(new PutCommand({
    TableName: TABLE_NAME,
    Item: {
      PK: `SEARCH#${keyword.toLowerCase()}`,
      SK: `PHOTO#${photo.id}`,
      GSI4PK: `SEARCH#${keyword.toLowerCase()}`,
      GSI4SK: `PHOTO#${photo.id}`,
      EntityType: 'Photo',
      EntityId: photo.id,
      Type: 'SearchIndex'
    }
  }));
}

FilterExpressionによるセキュリティフィルタリング

検索結果から非公開データを除外するために、FilterExpressionを活用します。

※ GSI射影の前提条件
以下のコードでFilterExpressionを使用してData.privacy属性をフィルタリングするには、GSIのProjectionTypeALLであるか、またはINCLUDEData属性を含めている必要があります。射影に含まれていない属性を参照するとクエリが失敗します。
// TypeScript (AWS SDK v3): カテゴリ検索の実装例
import { QueryCommand, QueryCommandInput } from '@aws-sdk/lib-dynamodb';

const params: QueryCommandInput = {
  TableName: this.tableName,
  IndexName: 'GSI2-CategorySearch',
  KeyConditionExpression: 'GSI2PK = :pk',
  // PRIVATE写真をフィルタ(検索APIは常にpublic写真のみ)
  // ※ GSIのProjectionにData属性が含まれている前提
  FilterExpression: '#data.#privacy = :public',
  ExpressionAttributeNames: {
    '#data': 'Data',
    '#privacy': 'privacy',
  },
  ExpressionAttributeValues: {
    ':pk': `CATEGORY#${categoryId}`,
    ':public': 'public',
  },
  Limit: query.limit ?? 20,
  ScanIndexForward: false, // 新しい順
};

重要:二重防御の実装

セキュリティ上重要なフィルタリングは、FilterExpressionとアプリケーション層の両方で実装することを推奨します。

// TypeScript: DynamoDBクエリ実行後のアプリケーション層フィルタ
const items = (result.Items ?? [])
  .filter((item) => {
    const data = item.Data as { privacy?: string };
    const isPublic = data?.privacy === 'public';
    return item.Type === 'PhotoInfo' && isPublic;
  })
  .map((item) => this.transformToSearchResult(item));

GraphQLとの統合

AWS AppSyncを使用してGraphQL APIを提供する場合、入力値の検証とマッピングが重要です。

# GraphQL Schema
input PhotoSearchQuery {
  keyword: String
  category_ids: [Int!]
  tags: [String!]
  license_ids: [Int!]
  safe_for_work: Boolean
  privacy: Privacy  # ← 悪意のある入力に注意
  min_date: String
  max_date: String
  sort: PhotoSortOption
  limit: Int
  next_key: String
}
// TypeScript: Resolver実装 - セキュリティ対策
const searchQuery: SearchQuery = {
  q: query.keyword,
  category: query.category_ids?.[0]?.toString(),
  filters: {
    // safe_for_work の反転マッピング
    includeNsfw: query.safe_for_work !== undefined
      ? !query.safe_for_work
      : undefined,
    // 重要: privacy引数に関わらず常にpublicOnly=true
    publicOnly: true,
  },
};

パフォーマンス考慮事項

1. FilterExpressionの制限

FilterExpressionはクエリにフィルタリングを行うため、読み取りキャパシティは消費されます。大量のデータがフィルタで除外される場合は、GSI設計を見直すことを検討してください。

2. 複数アイテムの一括取得

複数のアイテムを効率的に取得するには、BatchGetCommandを使用します。Query の並列実行よりも効率的でコストも低くなります。

// TypeScript (AWS SDK v3): BatchGetCommandで一括取得
import { BatchGetCommand } from '@aws-sdk/lib-dynamodb';

const BATCH_SIZE = 100; // BatchGetItemは最大100アイテム

for (let i = 0; i < photoIds.length; i += BATCH_SIZE) {
  const batch = photoIds.slice(i, i + BATCH_SIZE);

  const result = await this.docClient.send(new BatchGetCommand({
    RequestItems: {
      [this.tableName]: {
        Keys: batch.map((id) => ({
          PK: `PHOTO#${id}`,
          SK: 'INFO',
        })),
      },
    },
  }));

  const items = result.Responses?.[this.tableName] ?? [];
  // 結果を処理...
}
※ BatchGetItem vs Query の使い分け
  • BatchGetItem: PK+SKが既知の複数アイテムを一括取得する場合に最適。最大100アイテム/リクエスト。
  • Query: PKに対して条件付きでSKの範囲を検索する場合に使用。単一アイテム取得にはオーバーヘッドがある。

実装のポイントまとめ

  1. GSI設計を事前に検討:検索パターンに応じた適切なGSI設計が鍵
  2. 検索インデックスの事前構築:データ登録時にキーワードインデックスを作成(完全一致検索用)
  3. FilterExpressionの活用:クエリ時にセキュリティフィルタリング(GSI射影に対象属性が必要)
  4. 二重防御:DB層とアプリ層の両方でフィルタリング
  5. 入力値の検証:GraphQL引数の悪意ある入力を無視する実装

この方法が向いているケース

  • 検索パターンが限定的(カテゴリ、タグ、日付範囲など)
  • 完全一致のキーワード検索で十分(曖昧検索が不要)
  • データ量が中規模(数十万件程度まで)
  • コスト重視のプロジェクト

OpenSearchを検討すべきケース

  • 曖昧検索(typo許容)が必要
  • 自然言語処理・形態素解析が必要
  • 関連度スコアリングが必要
  • 複雑なアグリゲーション(集計)が必要
  • データ量が大規模(数百万件以上)

参考リンク

まとめ

DynamoDB単体でも、適切なGSI設計とFilterExpressionの活用により、事前構築したインデックスに対する完全一致のキーワード検索を実現できます。OpenSearchの導入は強力ですが、コストとのトレードオフを考慮し、プロジェクトの要件に合った選択をしましょう。

本記事で紹介した実装パターンは、実際にフィギュア写真共有サービス「Figure Fan」のバックエンド開発で使用しているものです。月額コストを抑えながら、セキュリティを確保した検索機能を提供しています。