OpenSearchのパフォーマンスはインデックス設計とクエリで決まる ― 設定に頼らない実践ガイド

新田 幸子

OpenSearchのパフォーマンス改善というと、refresh_intervalやレプリカ数といった設定のチューニングに目が行きがちです。しかし実際には、インデックスの設計と検索クエリの書き方がパフォーマンスに与える影響の方がはるかに大きいケースが多々あります。

設定チューニングは「エンジンの調整」、インデックス設計とクエリ最適化は「車体の設計」のようなもの。土台がしっかりしていなければ、いくらエンジンを調整しても限界があります。

この記事では、OpenSearchの内部構造(シャード・セグメント・Lucene)の仕組みを踏まえつつ、インデックスの作り方と検索クエリの書き方に絞って、パフォーマンスを引き出すための実践的な手法を解説します。

インデックス設計編

マッピングは明示的に定義する

OpenSearchにはdynamicマッピングという便利な機能があり、ドキュメントを投入するとフィールドの型を自動推定してマッピングに追加してくれます。開発初期には便利ですが、本番環境ではパフォーマンス上の問題を引き起こす原因になります。

dynamicマッピングが有効な状態で、構造がバラバラなドキュメントを大量に投入すると、フィールドが際限なく増え続けます。マッピング情報はJVMヒープに乗るため、フィールドが増えるほどメモリ消費が膨らみ、インデクシングや検索のパフォーマンスが悪化します。

PUT /my-index
{
  "mappings": {
    "dynamic": false,
    "properties": {
       "user_id": { "type": "keyword" },
       "user_name": { "type": "text", "analyzer": "standard" },
       "email": { "type": "keyword" },
       "created_at": { "type": "date" },
       "status": { "type": "keyword" }
    }
  }
}

"dynamic": falseにすると、マッピングに定義されていないフィールドは無視されます(_sourceには保存されるが、インデクシングされない)。より厳格にしたい場合は"dynamic": "strict"にすると、未定義フィールドが来た時点でエラーになります。

keyword型とtext型を正しく使い分ける

フィールドの型選択は、検索パフォーマンスに直結する最も基本的な設計判断です。

keyword型は、値をそのまま1つのトークンとして格納します。分析(トークナイズ)を行わないため、完全一致検索、フィルタリング、ソート、アグリゲーションに適しています。内部的にはdoc_valuesがデフォルトで有効になっており、集計やソートの際にヒープを消費しません。

text型は、値をアナライザーで分割(トークナイズ)して格納します。「東京の天気」が「東京」「天気」のように分割され、全文検索が可能になります。一方で、text型はそのままではソートやアグリゲーションに使えません。

// keyword型: ステータスコード、ユーザーID、メールアドレスなど
"status": { "type": "keyword" }
"user_id": { "type": "keyword" }

// text型: 自由入力の文章、商品説明、ブログ記事本文など
"description": { "type": "text", "analyzer": "standard" }

迷ったときの判断基準はシンプルです。「このフィールドで部分一致や全文検索をするか?」 するならtext型、しないならkeyword型です。

multi-fieldsで両方の恩恵を受ける

実務では「全文検索もしたいし、完全一致フィルタやソートもしたい」というフィールドが出てきます。その場合はmulti-fieldsを使って、1つのフィールドを複数の型でインデクシングします。

PUT /products
{
  "mappings": {
    "properties": {
      "product_name": {
        "type": "text",
        "analyzer": "standard",
        "fields": {
          "raw": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

こうすると、product_nameで全文検索、product_name.rawで完全一致フィルタやソートが可能になります。

// 全文検索
{ "query": { "match": { "product_name": "ワイヤレスイヤホン" } } }

// 完全一致フィルタ + ソート
{
  "query": {
    "bool": {
      "filter": { "term": { "product_name.raw": "Sony WF-1000XM5" } }
    }
  },
  "sort": [{ "product_name.raw": "asc" }]
}

不要なフィールドのインデクシングを無効化する

すべてのフィールドをインデクシングする必要はありません。検索に使わないフィールドのインデクシングを無効化すれば、ヒープ消費とディスク使用量を削減できます。

"index": false: フィールドはインデクシングされず検索不可になりますが、_sourceから取得は可能です。「保存はするが検索対象にしない」フィールドに使います。

"enabled": false: フィールドの解析もインデクシングも完全にスキップします。JSONとして_sourceに保存されるだけです。生データを丸ごと保管しておきたいが、中身を検索する必要がないオブジェクトフィールドに有効です。

PUT /logs
{
  "mappings": {
    "properties": {
      "message": { "type": "text" },
      "timestamp": { "type": "date" },
      "level": { "type": "keyword" },
      "raw_request": { "type": "object", "enabled": false },
      "internal_id": { "type": "keyword", "index": false, "doc_values": false }
    }
  }
}

raw_requestはリクエストの生データを丸ごと保管するが検索しない想定。internal_idは結果には含めたいが検索もソートもしない想定で、doc_valuesも無効化しています。

nested型はできるだけ避ける

nested型はOpenSearchの中でもパフォーマンスへの影響が大きいフィールド型です。Luceneはnested型の各オブジェクトを別々の内部ドキュメントとして格納するため、見かけ上1件のドキュメントでも内部的には数十〜数百件のドキュメントになることがあります。

加えて、nested型には1インデックスあたり50フィールド、1ドキュメントあたり10,000オブジェクトという上限があります。

nested型が本当に必要なのは、オブジェクト内のフィールド間の関連性を保って検索する必要がある場合だけです。たとえば「赤色かつXLサイズのバリエーション」のように、色とサイズの組み合わせを正確にマッチさせたい場合です。

それ以外の場合は、以下の代替手段を検討しましょう。

  • フラット化(デノーマライズ): オブジェクトの階層構造を解体して、フラットなフィールドにする。
// nested型を使わない設計
{
  "product_name": "Tシャツ",
  "variant_color": "赤",
  "variant_size": "XL",
  "variant_price": 2980
}
  • flat_object型: 構造が不定のJSONをそのまま1つのフィールドとして格納する。中身の個別フィールドでの集計はできませんが、メモリ効率が良い。
{
  "mappings": {
    "properties": {
      "metadata": { "type": "flat_object" }
    }
  }
}

doc_valuesとfielddataを理解する

ソートやアグリゲーションに使うデータ構造について、正しく理解しておくことが重要です。

doc_valuesはディスクベースのカラムナーストレージで、keyword型・数値型・日付型などではデフォルトで有効です。ヒープをほとんど消費しないため、アグリゲーションやソートに安全に使えます。

fielddataはtext型フィールドのアグリゲーション・ソートに使われるインメモリのデータ構造で、デフォルトで無効化されています。有効にするとヒープを大量に消費し、OOMの原因になるため、有効化は推奨されません。

text型フィールドでアグリゲーションやソートが必要な場合は、fielddataを有効にするのではなく、multi-fieldsでkeyword型のサブフィールドを用意して、そちらを使いましょう。

// BAD: text型でアグリゲーション → fielddataが必要でヒープ爆発
{ "aggs": { "by_category": { "terms": { "field": "category" } } } }

// GOOD: keyword型のサブフィールドでアグリゲーション
{ "aggs": { "by_category": { "terms": { "field": "category.raw" } } } }

ソートやアグリゲーションに使わないフィールドは、"doc_values": falseを設定するとディスク使用量を削減できます。

検索クエリ編

filter contextを最大限に活用する

OpenSearchのBoolクエリにはquery contextとfilter contextの2つの実行モードがあります。この使い分けがクエリパフォーマンスを大きく左右します。

query context(must/should句)は「このドキュメントはどの程度マッチするか?」を評価し、関連度スコアを計算します。スコア計算はCPUコストが高い処理です。

filter context(filter/must_not句)は「このドキュメントはマッチするか否か?」だけを評価し、スコアを計算しません。さらに、filterの結果は自動的にキャッシュされるため、繰り返し実行される同じ条件のクエリが高速になります。

GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "description": "ワイヤレス ノイズキャンセリング" } }
      ],
      "filter": [
        { "term": { "status": "active" } },
        { "range": { "price": { "gte": 5000, "lte": 30000 } } },
        { "term": { "category": "イヤホン" } }
      ]
    }
  }
}

この例では、descriptionの全文検索だけがスコア計算の対象で、ステータス・価格帯・カテゴリのフィルタリングはスコアなしで実行されキャッシュされます。

原則: スコアリングが不要な条件はすべてfilterに入れる。これだけでクエリパフォーマンスが大きく改善します。

term系クエリとmatch系クエリを正しく使い分ける

termクエリは入力値をそのまま検索します。分析(トークナイズ)を行いません。keyword型フィールドに対して使います。

matchクエリは入力値をアナライザーで分析してから検索します。text型フィールドに対して使います。

ありがちな間違いは、text型フィールドにtermクエリを使うことです。text型フィールドは格納時にトークナイズされているので、termクエリで元の文字列をそのまま指定してもマッチしません。

// text型フィールド "description" に "quick brown fox" が格納されている場合

// NG: termクエリ → "quick brown fox" というトークンは存在しないのでマッチしない
{ "query": { "term": { "description": "quick brown fox" } } }

// OK: matchクエリ → "quick", "brown", "fox" に分割して検索
{ "query": { "match": { "description": "quick brown fox" } } }

// OK: keyword型フィールドにtermクエリ
{ "query": { "term": { "status": "active" } } }

keyword型フィールドへのtermクエリは、さらにfilterに入れるとキャッシュの恩恵も受けられます。

_sourceフィルタリングで転送量を減らす

検索結果には、デフォルトでドキュメントの_source(全フィールド)が含まれます。必要なフィールドだけに絞ることで、ネットワーク転送量とデシリアライズのコストを削減できます。

GET /orders/_search
{
  "_source": ["order_id", "status", "total_amount"],
  "query": {
    "bool": {
      "filter": { "term": { "user_id": "user_123" } }
    }
  }
}

特にフィールド数が多いドキュメントや、大きなテキストフィールドを含むドキュメントでは効果が顕著です。

先頭ワイルドカードを避ける

*opensearchのような先頭にワイルドカードを置くクエリは、Luceneがインデックスの全タームを走査する必要があるため、極めて遅くなります。

// BAD: 全タームを走査 → 非常に遅い
{ "query": { "wildcard": { "url": "*opensearch*" } } }

代替手段として以下が使えます。

wildcard型フィールド: 部分文字列検索に特化したフィールド型です。インデクシング時にn-gram的なデータ構造を構築するため、ワイルドカード検索が高速になります。

PUT /my-index
{
  "mappings": {
    "properties": {
      "url": { "type": "wildcard" }
    }
  }
}

// これが高速に動く
{ "query": { "wildcard": { "url": "*opensearch*" } } }

n-gramアナライザー: text型フィールドにn-gramトークナイザーを設定すれば、matchクエリで部分文字列検索が可能になります。

スクリプトの使用を最小限にする

scriptクエリやscript_scoreは、マッチした全ドキュメントに対してスクリプトを実行するため、データ量に比例して遅くなります。

// BAD: 全ドキュメントでスクリプト実行 → 遅い
{
  "query": {
    "script": {
      "script": {
        "source": "doc['price'].value * doc['quantity'].value > 1000"
      }
    }
  }
}

代替策はインデクシング時に計算結果を格納しておくことです。

// GOOD: インデクシング時に計算済みフィールドを持つ
PUT /orders
{
  "mappings": {
    "properties": {
      "price": { "type": "integer" },
      "quantity": { "type": "integer" },
      "total_value": { "type": "integer" }
    }
  }
}

// Ingest Pipelineで自動計算
PUT _ingest/pipeline/calc_total
{
  "processors": [
    {
      "script": {
        "source": "ctx.total_value = ctx.price * ctx.quantity;"
      }
    }
  ]
}

// 検索はシンプルなrange → 高速
{ "query": { "range": { "total_value": { "gte": 1000 } } } }

スクリプトの実行コストをインデクシング時に前払いすることで、検索時のパフォーマンスを大幅に改善できます。

ページネーションはsearch_afterを使う

OpenSearchのデフォルトのページネーション(from/size)は、深いページほど遅くなります。from: 10000, size: 10と指定すると、内部的には10,010件のドキュメントを取得してから最初の10,000件を捨てるためです。デフォルトではfrom + sizeが10,000を超えるとエラーになります。

search_afterは、前回の検索結果の最後のドキュメントのソート値を次のリクエストに渡すことで、効率的にページネーションを行う仕組みです。

// 1ページ目
GET /logs/_search
{
  "size": 20,
  "sort": [
    { "timestamp": "desc" },
    { "_id": "asc" }
  ],
  "query": { "match": { "level": "ERROR" } }
}

// レスポンスの最後のドキュメントのsort値を取得
// "sort": ["2025-12-01T10:30:00Z", "abc123"]

// 2ページ目: search_afterに前回のsort値を渡す
GET /logs/_search
{
  "size": 20,
  "sort": [
    { "timestamp": "desc" },
    { "_id": "asc" }
  ],
  "query": { "match": { "level": "ERROR" } },
  "search_after": ["2025-12-01T10:30:00Z", "abc123"]
}

search_afterを使うときのポイント:

  • ソートに一意なフィールド(_idなど)を含めて、ソート順序がユニークになるようにする
  • 一貫したスナップショットが必要な場合はPoint in Time(PIT)と組み合わせる
  • 「前のページに戻る」操作には向かない(UIではURL直打ちやブックマーク型のページネーションに適している)

ルーティングで検索対象シャードを絞る

デフォルトでは、検索クエリはインデックスの全シャードに送られます。しかし、ルーティングを指定すれば該当シャードだけにクエリを送ることができ、パフォーマンスが向上します。

// インデクシング時: tenant_aのデータを同じシャードに集約
POST /multi-tenant-index/_doc/1?routing=tenant_a
{ "tenant": "tenant_a", "data": "..." }

// 検索時: tenant_aのシャードだけに問い合わせ
GET /multi-tenant-index/_search?routing=tenant_a
{ "query": { "match": { "data": "検索キーワード" } } }

24シャードのインデックスで、ルーティングによって1シャードだけに問い合わせれば、クエリの分散・集約コストが1/24になります。マルチテナント構成やユーザー単位のデータアクセスパターンに特に有効です。

ただし、ルーティング値の偏りによるホットスポットには注意が必要です。特定のテナントにデータが集中すると、そのシャードがボトルネックになります。

データ投入編

Bulk APIでまとめて投入する

1件ずつドキュメントを投入すると、毎回HTTPリクエスト→ルーティング→シャードへの書き込み→レスポンスのサイクルが発生します。Bulk APIを使えば、1回のリクエストで複数のドキュメントをまとめて投入でき、このオーバーヘッドを大幅に削減できます。

POST /_bulk
{ "index": { "_index": "products" } }
{ "product_id": "001", "name": "ワイヤレスイヤホン", "price": 15000 }
{ "index": { "_index": "products" } }
{ "product_id": "002", "name": "Bluetoothスピーカー", "price": 8000 }
{ "index": { "_index": "products" } }
{ "product_id": "003", "name": "USBマイク", "price": 12000 }

最適なバルクサイズの見つけ方:

  • 5〜15MBを目安にスタートする
  • 徐々にサイズを増やし、スループットが改善しなくなるポイントを見つける
  • ドキュメントが小さい場合はドキュメント数(100〜1,000件)で調整する
  • 429(Too Many Requests)エラーが出たら、サイズを下げるか指数バックオフでリトライする

並列投入のコツ: 1スレッドにつき1つのバルクリクエストを送り、複数スレッドで並列化する。1スレッドで複数リクエストを同時に送るのではなく、スレッド数で並列度を調整する。

アグリゲーション最適化編

フィルタしてから集計する

アグリゲーションは対象ドキュメント全体に対して実行されるため、事前にフィルタでドキュメント数を絞り込むことがパフォーマンス改善の基本です。

GET /sales/_search
{
  "size": 0,
  "query": {
    "bool": {
      "filter": [
        { "term": { "region": "asia" } },
        { "range": { "date": { "gte": "2025-01-01" } } }
      ]
    }
  },
  "aggs": {
    "top_products": {
      "terms": { "field": "product_name.raw", "size": 10 }
    }
  }
}

"size": 0は検索結果のドキュメント自体を返さず、アグリゲーション結果だけを返す設定です。集計結果だけが必要な場合は必ず指定しましょう。

keyword型フィールドで集計する

アグリゲーションは必ずkeyword型フィールド(または数値型・日付型)に対して実行してください。text型フィールドに対してアグリゲーションを実行しようとすると、fielddataの有効化が必要になり、ヒープを大量に消費します。

バケット数を制限する

termsアグリゲーションのsizeパラメータはデフォルトで10ですが、大きな値を指定するとメモリ消費とCPUコストが増えます。本当に必要な数だけ取得しましょう。

大規模データにはsamplerを使う

数億件のドキュメントに対してアグリゲーションを実行する場合、sampler aggregationで代表的なサンプルだけを対象にすることで、精度を保ちつつパフォーマンスを改善できます。

{
  "aggs": {
    "sample": {
      "sampler": { "shard_size": 10000 },
      "aggs": {
        "top_categories": {
          "terms": { "field": "category.raw", "size": 20 }
        }
      }
    }
  }
}

まとめ: チェックリスト

パフォーマンスを意識したOpenSearchの使い方をチェックリストにまとめます。

インデックス設計:

  • dynamicマッピングを無効化("dynamic": false)して明示的にマッピングを定義しているか
  • keyword型とtext型を正しく使い分けているか
  • 全文検索もフィルタも必要なフィールドにmulti-fieldsを設定しているか
  • 検索しないフィールドに"index": false"enabled": falseを設定しているか
  • nested型を使わずに済む設計になっているか
  • ソート・集計に使わないフィールドのdoc_valuesを無効化しているか

検索クエリ:

  • スコアリング不要な条件はすべてfilter contextに入れているか
  • term系クエリをtext型フィールドに使っていないか
  • _sourceフィルタリングで必要なフィールドだけ取得しているか
  • 先頭ワイルドカードを避けているか(必要ならwildcard型フィールドを使っているか)
  • スクリプトをクエリ内で使わず、インデクシング時に計算しているか
  • 深いページネーションにsearch_afterを使っているか
  • マルチテナントなどでルーティングを活用しているか

データ投入:

  • Bulk APIで5〜15MB単位で投入しているか
  • 並列投入を活用しているか

アグリゲーション:

  • 集計前にフィルタで対象を絞り込んでいるか
  • keyword型フィールドで集計しているか
  • バケット数を必要最小限にしているか

これらの設計とクエリの改善は、OpenSearchの設定変更と違ってインデックスの再作成が必要になるものも多いです。だからこそ、最初の設計段階でパフォーマンスを意識することが重要です。あとから直すコストは、最初に正しく設計するコストよりもはるかに大きくなります。

参考ドキュメント

新田 幸子/ Drupalエンジニア

北海道在住のDrupalエンジニアです。バックエンドを主に担当しています。三度の飯よりAIが好きです。趣味はドライブ、猫と遊ぶことです。

新田 幸子 の書いた記事一覧

最新の関連記事

Download 資料ダウンロード

Drupalでの開発・運用、サーバー構築、Webサイト構築全般、制作費用などに関してお気軽にご相談ください。


Contact お問い合わせ

Drupalでの開発・運用、サーバー構築、Webサイト構築全般についてお気軽にご相談ください。専門スタッフによるDrupal無料相談も行なっております。