ElasticsearchのクエリはJSONで書きます。構文そのものは素直で、公式リファレンスを見ながら組めば最初の一本はすぐ動く構造です。ところが検証データ千件では見えなかった問題が、本番のデータ量で一斉に表面化します。並び順が説明できない、11ページ目でエラーになる、総件数が10000で止まる。いずれもクエリの書き方ではなく、クエリDSLが前提にしている仕組みを知らないまま組んだことが原因です。9.5系(2026年8月時点)を前提に、実装前に決めておくべき分岐を順に見ていきます。
まとめ:クエリを書き始める前に決めておく6つの値
クエリDSLで最初に決めるのは構文ではなく、結果集合をどう扱うかという6点です。ここが決まっていれば、あとは公式リファレンスの引き写しで足ります。逆にここを決めずに書き始めると、データ量が増えた段階で構造から作り直しになります。
| 決める値 | 選択肢 | 決め方の基準 |
|---|---|---|
| 並び順の根拠 | 関連度スコア/指定フィールド | 利用者が順位に納得を求めるか |
| 条件の置き場所 | query/filter | スコアへ寄与させるかどうか |
| 文字列一致の粒度 | match/match_phrase/term | 対象フィールドの型で決まる |
| ページの辿り方 | from+size/search_after | 1万件を超えて辿るかどうか |
| 総件数の精度 | 既定の下限値/正確な件数 | 件数表示が仕様に含まれるか |
| 集計の対象範囲 | queryの結果/post_filter | ファセットに選択肢を残すか |
結論を先に置きます。スコアを使わない条件はすべてfilterへ寄せてください。これだけで体感速度が変わり、頻出条件は自動でキャッシュされます。そしてページングは、11ページ目より先へ行く可能性が少しでもあるなら最初からsearch_afterで組んでおくのが安全です。後から差し替えると、APIの応答形式とフロント側の実装の両方に手が入ります。製品としての構造や採用可否そのものはElasticsearchとは?転置インデックスとシャード設計・ライセンス系列で決める採用可否で扱っているため、本記事はクエリ本体の書き方に絞ります。
二つのコンテキスト|スコアを計算するかどうかで実行の重さが変わる
クエリDSLの条件式は、置かれた場所によって二つの解釈のどちらかを受けます。公式リファレンスでは、これをqueryコンテキストとfilterコンテキストと呼び、同じtermという条件でも置き場所で動作が変わるとの説明です。構文の暗記より先に、この分岐を理解しておくほうが実務では効きます。
queryコンテキスト|_scoreを返す側と、その値に意味がある条件
queryコンテキストに置かれた条件は「この文書はこの条件にどれくらい合致しているか」という問いに答え、結果を_scoreというメタデータフィールドへ返します。値が大きいほど合致度が高いという扱いで、既定の並び順はこのスコアの降順になります。全文検索の語句のように、合致の度合いに幅がある条件はここへ置いてください。
逆に言えば、合致度に幅のない条件をここへ置く意味はありません。公開状態がpublishedかどうかは合致するかしないかの二値で、部分的に合致することはないからです。それでもqueryコンテキストへ置けば、Elasticsearchはスコアを律儀に計算します。計算した値は並び順に何の影響も与えないまま捨てられます。
filterコンテキスト|スコアを計算せず頻出条件が自動でキャッシュされる
filterコンテキストに置かれた条件は「この文書はこの条件に合致するか」という二値の問いに答えます。公式リファレンスの表現では答えは単純にyesかnoで、スコアは計算されません。計算しないぶん実行は軽く、さらにElasticsearchは頻繁に使われるフィルタを自動的にキャッシュします。
この自動キャッシュが効くと、二度目以降の同じ条件はビットセットの照合だけで済みます。公開状態、テナントID、カテゴリ、日付範囲のように、多くのリクエストで同じ値が繰り返し現れる条件ほど恩恵が大きい仕組みです。キャッシュが実際に効いているかどうかを含めた性能面の調べ方はElasticsearchのチューニング|メモリ配分とシャード設計・遅いクエリの特定手順で扱っています。
POST /products/_search
{
"query": {
"bool": {
"must": [ { "match": { "title": "ワイヤレス イヤホン" } } ],
"filter": [
{ "term": { "status": "published" } },
{ "range": { "price": { "lte": 20000 } } }
]
}
}
}
どちらへ置くかの判定|並び順を利用者が期待するかどうかで決める
迷ったときの判定は一点で足ります。その条件が並び順の説明に使われるかどうかです。検索窓に入力された語は、合致の強さが上位表示の根拠になるためqueryコンテキストへ置きます。絞り込みのチェックボックスやテナントの識別子は、順位の根拠にならないのでfilterコンテキストへ寄せてください。
この判定を通すと、実際の業務システムでは条件の大半がfilter側へ移ります。全文検索の語句が一つ、残りは全部フィルタという形が典型です。並び順を関連度ではなく更新日時や価格で指定する画面なら、query側は空でも構いません。その場合はmatch_allとフィルタの組み合わせにして、スコア計算を丸ごと省きます。
matchとtermの分岐|フィールドの型で機械的に決まる書き分け
次に詰まるのが文字列の一致です。matchとtermはどちらも文字列を照合しますが、この二つは好みで選ぶものではありません。対象フィールドがtext型かkeyword型かで、使うべき側が機械的に決まります。型を決めないまま書き始めると、ここで必ず詰まります。型の割り当てとその決め方はElasticsearchのマッピング設計|型選定とdynamicの制御、作り直しの手順で扱っています。
termがtext型で当たらない理由|解析器が値を分解して小文字化する
termは与えられた語をそのまま照合します。一方text型のフィールドは、索引に入る時点で解析器によって加工されています。標準の解析器による処理は、句読点を落とし、語へ分割し、小文字へ揃えるものです。公式リファレンスの例ではQuick Brown Foxesという値がquick、brown、foxの三語として索引されると説明されています。
つまり元の文字列は索引のどこにも残っていません。そこへtermで元の文字列を投げても一致するものがなく、結果は0件になります。公式リファレンスはtext型に対するtermの使用を避けるよう明示的に警告し、matchを使うよう指示しています。matchは検索語のほうも同じ解析器へ通すため、両側の形が揃って初めて照合が成立するという仕組みです。
GET /products/_search
{ "query": { "term": { "title": "Quick Brown Foxes" } } }
matchのoperator|既定のORをどこまで締めるかの判断
matchに複数語を渡すと、既定では解析後の語のいずれかに合致した文書が返ります。「ワイヤレス イヤホン」で検索したとき、イヤホンだけに合致した有線製品も結果へ入るということです。件数は稼げますが、利用者の期待とはずれます。
operatorをandにすると全語の合致を要求できます。ただし語数が増えるほど0件になりやすく、日本語では解析器の分割単位に結果が左右されます。中間を取るならminimum_should_matchに75%のような割合を指定して、語数に応じて必要な合致数を変える書き方が扱いやすいでしょう。日本語の文章を対象にするなら、分割の仕方そのものを決める必要があります。その分割を決めるkuromojiの設定は、Elasticsearchの日本語全文検索|kuromojiのanalyzer構成と辞書追加、N-gram併用の判断で扱っています。
完全一致が要るときの第三の道|keywordサブフィールドへ寄せる
商品コードやステータスのように完全一致だけが要る値は、最初からkeyword型で索引します。keyword型は解析されないため、termがそのまま当たります。集計のキーやソートにも使えるので、識別子の類はここへ寄せるのが定石です。
問題は、全文検索も完全一致も両方欲しいフィールドです。商品名を語で検索しつつ、ブランド名では厳密に絞りたいという要件はよくあります。この場合はtext型のフィールドにkeywordのサブフィールドを持たせ、用途で参照先を切り替えます。既定のマッピングでもtitle.keywordという形で自動的に用意されますが、長い文字列は既定で256文字を超えた分が索引されない点には注意してください。
boolの組み立て|四つの句を役割で割り当てて条件追加に耐えさせる
実務のクエリはほぼboolクエリになります。must、filter、should、must_notという四つの句を持ち、役割はそれぞれ別です。役割どおりに割り当てておくと、後から条件が一つ増えても構造を壊さずに追記できます。
mustとfilterの割り当て|検索窓に入った語だけをmustへ置く
公式リファレンスによればmustは「合致する文書に必ず現れなければならず、スコアに寄与する」句、filterは「必ず現れなければならないが、スコアは無視される」句です。必須条件である点は同じで、違いはスコアへの寄与だけと理解してください。
したがって割り当ての基準は先ほどの判定と同じになります。検索窓に入った語だけをmustへ置き、残りの必須条件はfilterへ並べます。この形にしておけば、絞り込み条件が10個に増えてもスコア計算の負荷は増えません。
shouldの既定値が変わる仕様|0と1に分かれる条件と指定の要否
shouldは任意条件で、合致すればスコアが加算されます。ここに落とし穴があります。minimum_should_matchの既定値が、同じboolの中にmustやfilterがあるかどうかで変わるのです。
公式リファレンスの規定はこうです。should句が一つ以上あり、must句もfilter句も無い場合、既定値は1になります。それ以外の場合、既定値は0です。つまりフィルタを一つ足しただけで、それまで必須だったshouldが任意条件へ変わります。件数が急に増えた原因がこれだったという事故は珍しくありません。shouldを使うならminimum_should_matchを明示的に書いてください。
{
"bool": {
"must": [ { "match": { "body": "保証 期間" } } ],
"filter": [ { "terms": { "category": [ "audio", "wearable" ] } } ],
"should": [ { "term": { "brand": "acme" } } ],
"must_not": [ { "term": { "status": "archived" } } ],
"minimum_should_match": 0
}
}
must_notとexists|値が無い行を除くときに起きる取りこぼし
must_notは否定条件で、公式リファレンスによれば合致した文書のスコアは0になります。単独で使うとすべての文書のスコアが0になるため、並び順は別途指定する前提で組みます。
否定でよく起きるのが、値が入っていない文書の扱いです。「ステータスがarchivedでないもの」をmust_notで書くと、ステータス自体が存在しない文書も結果へ入ります。JSONにキーが無い状態はarchivedではないからです。値の有無を条件にしたいならexistsを併用して、フィールドが存在する文書だけを対象に絞ってください。
ページング|from+sizeが1万件で止まる仕様とその先の三つの選択肢
ここが本番で最も多い作り直しの原因です。検索結果は既定で上位10件が返り、fromとsizeで次のページを指定できます。この形は直感的で、ページ番号をそのままfromへ変換できる仕組みです。ただし辿れる範囲には上限があります。
from+sizeの上限1万件|max_result_windowを上げない理由
公式リファレンスは、fromとsizeで1万件を超えて辿ることはできないと明記しています。これはindex.max_result_windowという索引設定による安全装置です。1ページ100件なら101ページ目でエラーになります。
設定値なので引き上げは可能ですが、そうすべきではありません。fromが大きいページを取り出すとき、各シャードはfromとsizeの合計件数を集めて調整ノードへ送り、そこで並べ直してから要求されたぶんだけを切り出します。ページを深く辿るほど、捨てるためだけに集めるデータが増える構造です。上限を10万へ引き上げれば、深いページでヒープを圧迫する経路を自分で開いたことになります。設定ではなく方式を変えてください。
search_afterとPIT|並びを固定して次ページを取る実装の型
1万件を超えて辿るならsearch_afterを使います。ページ番号ではなく、前のページの最後の文書のソート値を渡して続きを取る方式です。捨てるデータが発生しないため、何ページ進んでも負荷は一定に保たれます。
併用するのがPIT(point in time)です。ページを辿っている最中に索引が更新されると、同じ文書が二度現れたり抜けたりします。PITは索引の状態を固定し、開いた時点の内容に対してページングを続けられるようにする仕組みです。索引名の下の_pitへ保持期間を指定して開き、返ってきたIDを検索リクエストへ渡し、終わったら閉じます。PITを使う検索には_shard_docという同順位を解消するソート項目が自動的に加わるため、並びが安定します。
POST /_search
{
"size": 100,
"pit": { "id": "取得したPIT ID", "keep_alive": "1m" },
"sort": [ { "updated_at": "asc" }, { "_shard_doc": "asc" } ],
"search_after": [ 1756200000000, 4823 ]
}
scrollを新規に選ばない判断|残っている用途と置き換えの順序
古い記事では深いページングの解としてscrollが紹介されています。現在の公式リファレンスは、深いページングにscrollを推奨しないと明言しています。新規実装で選ぶ理由はありません。
用途が完全に消えたわけではなく、再索引のように全件を順に処理するバッチでは今も使われます。判断の順序としては、画面のページ送りはsearch_afterとPIT、全件処理は既存の再索引APIに任せ、それでも足りない場合にscrollを検討するという並びになります。既存実装がscrollで動いているなら、無理に急いで置き換える必要はありません。触る機会が来たときにsearch_afterへ寄せてください。
集計との組み合わせ|aggsは検索条件で絞られた結果集合の上で走る
件数の内訳やファセット表示には集計を使います。ここで押さえるのは、集計が独立した処理ではなく、queryで絞られた結果集合の上で走るという点です。この関係を理解していないと、ファセットの選択肢が消えていく画面ができあがります。
size=0で件数本体を返さない|集計だけを取り出すときの書き方
集計値だけが要るなら、sizeに0を指定して文書本体の返却を止めます。ダッシュボードの数値パネルや、件数の内訳だけを取るAPIではこの形が基本です。文書を運ばないぶん応答も軽くなります。
POST /orders/_search
{ "size": 0, "aggs": { "total": { "sum": { "field": "amount" } } } }
post_filter|絞り込んだ後もファセットの選択肢を残す仕組み
ファセット検索で「ブランドAで絞り込んだら、ブランドBの件数表示が消えた」という現象があります。queryにブランドの条件を入れると、集計もその結果集合の上で走るため、他のブランドが0件になるからです。
これを避けるのがpost_filterです。公式リファレンスによれば、post_filterによる絞り込みは集計が計算された後に検索結果へ適用され、集計結果には影響しません。ブランドの選択肢と件数は全体のまま残し、表示する商品だけをブランドAに限るという挙動になります。利用者が他の選択肢へ移れる画面にするなら、絞り込み条件のうちファセットの軸になっているものだけをpost_filterへ移してください。
POST /products/_search
{
"size": 20,
"query": { "match": { "title": "イヤホン" } },
"aggs": { "by_brand": { "terms": { "field": "brand" } } },
"post_filter": { "term": { "brand": "acme" } }
}
track_total_hits|総件数の既定1万件とgte表示が意味すること
検索結果の件数表示が実際の件数と合わないことがあります。track_total_hitsの既定値が10,000で、それを超える場合は正確に数えず打ち切るためです。応答のhits.total.relationがgteになっていれば、valueは下限値を示しているという意味になります。
正確な件数が仕様に含まれるならtrack_total_hitsをtrueにします。ただし全件を数えることになり、上位だけ分かれば十分なときに効く打ち切りの仕組みが働かなくなります。件数表示が本当に必要かを設計側で問い直し、不要なら既定のままにしておくのが軽い選択です。意味の近さで並べたいなら、別の検索の型を併用します。その実装はElasticsearchのベクトル検索で扱っています。
クエリDSLを直接書いてよい条件と、クライアント側へ寄せる場面の線引き
JSONを手で組み立てる方式は素直ですが、常に正解というわけではありません。書き方を覚えた次に決めるのは、アプリケーションのどこにこのJSONを置くかです。
採用条件|クエリDSLを直接書いてよい三つの前提とその確認手順
次の三つが揃うなら、クエリDSLを直接書いて問題ありません。第一に、検索条件の組み合わせが仕様として確定していること。条件が固定なら、テンプレート化したJSONへ値を差し込むだけで済みます。第二に、集計やファセットのように検索APIでしか書けない機能を使うこと。第三に、クエリの内容をレビューできる体制があることです。
確認手順としては、まずKibanaのDev Tools Consoleで動く形を作ります。画面側での検証手順はElasticsearchとKibanaの連携|接続設定とデータビュー定義・可視化の作り分け・権限分離の実務にまとめています。検証用のデータを先に入れておく必要があるので、投入の手順はElasticsearchのデータ投入|bulk APIのNDJSON形式と部分失敗の扱い、差分同期の設計を参照してください。動作を確定させてからアプリ側へ写す順序にすると、切り分けが楽になります。言語ごとのクライアントは、この形をそのまま組み立てる仕組みを持っています。
見送る場面|クライアントやES|QLへ寄せたほうが短く終わる条件
逆に、利用者の入力から条件を動的に組み立てる画面では、JSONを文字列連結で作るのは避けてください。括弧の対応が崩れやすく、入力値のエスケープも自前になります。公式クライアントのビルダを通すか、条件の組み合わせを列挙してテンプレートを持つほうが安全です。呼び出し口の設計とエラー処理はElasticsearchのREST API|エンドポイント体系とPUT・POSTの分岐、APIキーとエラー処理で扱っています。
集計を伴わない表形式の抽出だけなら、ES|QLというパイプ構文の問い合わせ言語のほうが短く書けます。BIツールからの参照や、運用者が手で叩く調査用の問い合わせにはこちら向きです。検索要件をどこまでクエリで解き、どこから索引設計やデータ連携の側で解くかという線引きに迷う場合は、データ分析基盤構築・MLOps構築支援で要件の整理からご相談いただけます。
よくある質問
queryとfilterのどちらへ置くか迷ったときの決め方はありますか?
その条件が並び順の説明に使われるかどうかで決めてください。検索窓に入力された語のように、合致の強さが上位表示の根拠になるものはqueryコンテキストへ置きます。公開状態やテナントIDのように合致が二値で決まるものはfilterコンテキストです。迷うということは順位の根拠ではないという意味なので、その場合はfilter側で構いません。
termクエリで日本語が1件も当たりません。何を確認しますか?
対象フィールドの型を確認してください。text型なら、値は解析器によって語へ分割され小文字化された状態で索引されているため、元の文字列をtermで投げても一致しません。matchへ変えるか、完全一致が要るならkeyword型のサブフィールドを参照します。マッピングを見れば、どちらの型で索引されているかがすぐ分かります。
index.max_result_windowを大きくすれば深いページングは通りますか?
エラーは消えますが、問題は残ります。深いページを取るとき各シャードはfromとsizeの合計件数を集めて調整ノードへ送るため、ページが深いほど捨てるためだけのデータ収集が増える構造です。上限を引き上げると、その負荷を許可することになります。1万件を超えて辿る要件があるならsearch_afterへ切り替えてください。
総件数が1万件以上としか出ないのはなぜですか?
track_total_hitsの既定値が10,000で、それを超える件数は正確に数えずに打ち切るためです。応答のhits.total.relationがgteなら、valueは下限値という意味になります。正確な件数が要るならtrueを指定しますが、全件を数えるぶん応答は遅くなります。ページャの表示を1万件以上で済ませられるなら既定のままが軽い選択です。
クエリDSLとES|QLはどちらを覚えればよいですか?
アプリケーションへ組み込む検索を書くならクエリDSLです。集計やファセット、関連度の調整はクエリDSL側の機能で、ES|QLでは同じことを表現できない領域があります。ES|QLは表形式の抽出や運用時の調査で短く書ける言語なので、用途が違うと考えてください。まずクエリDSLを押さえ、調査の場面でES|QLを足すのが実務的な順序です。
関連記事
- Elasticsearchとは?転置インデックスとシャード設計:本記事が前提にしている製品側の構造と採用可否の判断です
- ElasticsearchのREST APIとエラー処理の設計:クエリを投げる側のエンドポイントと認証の話です
- Elasticsearchのチューニングと遅いクエリの特定手順:書いたクエリが遅いときに調べる順序です
- Elasticsearchのデータ投入とbulk APIの設計:検証用のデータを索引へ入れる手順です
- ElasticsearchとKibanaの連携と権限分離:Dev Tools Consoleでクエリを検証する方法です
- ElasticsearchのILMと5フェーズの設計:検索対象の索引を世代で分ける運用です