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

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

はじめに

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

本記事では、OpenSearchを使用せずにDynamoDB単体で実用的な検索機能を実現する方法を、実際のプロジェクト(フィギュア写真共有サービス)での実装経験をもとに解説します。

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"   // 作成日時(降順ソート用)
  },
  "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: 写真登録時にキーワードインデックスを作成
const keywords = extractKeywords(photo.title + ' ' + photo.description);

for (const keyword of keywords) {
  await docClient.put({
    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を活用します。

// TypeScript: カテゴリ検索の実装例
const params: QueryCommandInput = {
  TableName: this.tableName,
  IndexName: 'GSI2-CategorySearch',
  KeyConditionExpression: 'GSI2PK = :pk',
  // PRIVATE写真をフィルタ(検索APIは常にpublic写真のみ)
  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. バッチ取得の最適化

// TypeScript: 並列クエリでバッチ取得
const BATCH_SIZE = 25;
for (let i = 0; i < photoIds.length; i += BATCH_SIZE) {
  const batch = photoIds.slice(i, i + BATCH_SIZE);
  const queries = batch.map((id) =>
    this.docClient.send(new QueryCommand({
      TableName: this.tableName,
      KeyConditionExpression: 'PK = :pk AND SK = :sk',
      ExpressionAttributeValues: {
        ':pk': `PHOTO#${id}`,
        ':sk': 'INFO',
      },
    }))
  );
  const results = await Promise.all(queries);
  // 結果を処理...
}

実装のポイントまとめ

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

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

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

OpenSearchを検討すべきケース

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

参考リンク

まとめ

DynamoDB単体でも、適切なGSI設計とFilterExpressionの活用により、実用的な検索機能を実現できます。OpenSearchの導入は強力ですが、コストとのトレードオフを考慮し、プロジェクトの要件に合った選択をしましょう。

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