- 技術 💻
Elasticsearch入門|インデックスと検索クエリの書き方・日本語検索
Elasticsearch は、JSON の文書を保存して、全文検索と集計を速く行うための検索エンジンです。 操作はすべて HTTP の REST API で、curl から JSON を送って行います。RDB にたとえると、インデックスが表、ドキュメントが行、フィールドが列、マッピングが表の定義にあたります。
# インデックス(表)にドキュメント(行)を1件入れて、全文検索する
curl -X PUT "$ES/products/_doc/1" -H 'Content-Type: application/json' \
-d '{"name": "ワイヤレスキーボード", "category": "pc", "price": 4980}'
curl -X GET "$ES/products/_search" -H 'Content-Type: application/json' \
-d '{"query": {"match": {"name": "キーボード"}}}'
| RDB | Elasticsearch |
|---|---|
| データベースの表(テーブル) | インデックス |
| 行 | ドキュメント(JSON) |
| 列 | フィールド |
| 表の定義(CREATE TABLE) | マッピング |
| 主キー | _id |
| SQL | Query DSL(JSON で書く検索の言語) |
GROUP BY・AVG |
集計(aggregations) |
確認環境:API の仕様、パラメータの既定値、kuromoji の部品と出力例は、Elasticsearch 9 系の公式ドキュメント(elastic.co/docs)で確かめました。Python のコードは、公式の Python クライアント elasticsearch 9.5.1(Python 3.12、Ubuntu 24.04)で実行し、送られた HTTP リクエスト(メソッド・URL・JSON 本文)を、リクエストを記録する擬似サーバーで受けて、記事の curl の例と同じ内容になることを確かめています。動的マッピングの ignore_above の値は Elasticsearch のソース(GitHub の main ブランチ)で確かめました(確認日 2026年10月13日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Elasticsearch入門|インデックスと検索クエリの書き方・日本語検索」
準備:起動と接続
ローカルで動かす
公式ドキュメントのローカル開発用の手順では、Docker(Docker Compose)がある環境で次の1行を実行すると、Elasticsearch(ポート 9200)と Kibana(ポート 5601)が起動します。ドキュメントでは、この構成は開発・試験用で、本番には使わないこととされています。
curl -fsSL https://elastic.co/start-local | sh
この記事の curl の例は、接続先と認証を環境変数にまとめて書いています。
export ES=http://localhost:9200
export ES_API_KEY='(発行した API キー)'
# 認証ヘッダーを付けて curl を呼ぶ関数(以下の例で使う)
escurl() { curl -s -H "Authorization: ApiKey $ES_API_KEY" -H "Content-Type: application/json" "$@"; }
escurl "$ES/" # バージョンなどが返れば接続できている
| 接続の形 | curl での書き方 |
|---|---|
| API キー | -H "Authorization: ApiKey $ES_API_KEY" |
| ユーザー・パスワード | -u elastic:$ELASTIC_PASSWORD |
| 自己署名の証明書(HTTPS) | --cacert http_ca.crt(-k は検証を止めるので試験用のみ) |
Kibana の Dev Tools を使うときは、curl -X GET "$ES/products/_search" -d '...' を GET products/_search と JSON の本文の形で書きます。
Python クライアント
pip install elasticsearch # 9.5.1 で確認。サーバーが 8 系なら 'elasticsearch>=8,<9'
Python クライアント 9.5.1 は、Accept: application/vnd.elasticsearch+json; compatible-with=9 というヘッダーを付けて送っていました。クライアントの大きな版(9 など)は、サーバーの版に合わせます。
インデックスとマッピングを作る
動的マッピング(自動で型が決まる)
マッピングを書かずにドキュメントを入れると、Elasticsearch が JSON の値から型を決めます(動的マッピング)。
| JSON の値 | 決まる型 |
|---|---|
true / false |
boolean |
| 整数 | long |
| 小数 | float |
| 日付の形の文字列 | date |
| それ以外の文字列 | text と、その下に .keyword(keyword 型、256文字を超える値は索引に入れない) |
| オブジェクト | object |
null |
フィールドを追加しない |
文字列は「全文検索用の text」と「完全一致用の .keyword」の2つが自動で作られます。 便利な反面、カテゴリのように完全一致しか使わない列にも text ができ、日本語の文章には日本語向けでないアナライザーが使われます。本番では、次のように明示的にマッピングを書きます。
明示的なマッピング
escurl -X PUT "$ES/products" -d '{
"settings": {
"number_of_shards": 1,
"analysis": {
"analyzer": {
"ja": {
"type": "custom",
"char_filter": ["icu_normalizer"],
"tokenizer": "kuromoji_tokenizer",
"filter": ["kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop", "kuromoji_stemmer", "lowercase"]
}
}
}
},
"mappings": {
"properties": {
"name": { "type": "text", "analyzer": "ja", "fields": { "raw": { "type": "keyword" } } },
"description": { "type": "text", "analyzer": "ja" },
"category": { "type": "keyword" },
"price": { "type": "integer" },
"in_stock": { "type": "boolean" },
"created_at": { "type": "date" }
}
}
}'
ja アナライザーは、後の「日本語検索」で説明します(analysis-kuromoji と analysis-icu のプラグインが必要)。
| 型 | 使いどころ | 検索 |
|---|---|---|
text |
文章・商品名など、言葉で探すもの | match(分割した語で探す) |
keyword |
ID・カテゴリ・タグ・状態など | term(完全一致)、並べ替え・集計 |
integer / long / float / scaled_float |
数値 | range、集計 |
date |
日時 | range、date_histogram |
boolean |
真偽 | term |
nested |
配列の中のオブジェクトを1件ずつ扱う | nested クエリ |
dense_vector |
ベクトル(意味検索) | knn |
1つの列を全文検索と並べ替え・集計の両方に使うときは、fields で text と keyword を両方持たせます(マルチフィールド)。 上の例では name で検索し、name.raw で並べ替え・集計できます。
マッピングの確認と変更
escurl "$ES/products/_mapping?pretty" # マッピングを見る
escurl -X PUT "$ES/products/_mapping" -d '{
"properties": { "maker": { "type": "keyword" } }
}' # 新しいフィールドの追加はできる
escurl -X DELETE "$ES/products" # インデックスを消す(データも消える)
作ったフィールドの型やアナライザーは、あとから変えられません。 変えるときは、新しいマッピングでインデックスを作り、_reindex でデータを移します。
escurl -X POST "$ES/_reindex" -d '{
"source": { "index": "products" },
"dest": { "index": "products_v2" }
}'
アプリからは別名(エイリアス)で参照しておくと、移したあとに別名を付け替えるだけで切り替えられます。
ドキュメントの CRUD
| 操作 | API | SQL でいうと |
|---|---|---|
| ID を指定して登録・上書き | PUT /products/_doc/1 |
INSERT(あれば置き換え) |
| ID を自動で付けて登録 | POST /products/_doc |
INSERT |
| 無いときだけ登録 | PUT /products/_create/1 |
INSERT(重複はエラー) |
| 1件取得 | GET /products/_doc/1 |
SELECT ... WHERE id = 1 |
| 一部の更新 | POST /products/_update/1 |
UPDATE ... SET |
| 1件削除 | DELETE /products/_doc/1 |
DELETE ... WHERE id = 1 |
| 条件で更新・削除 | _update_by_query / _delete_by_query |
UPDATE / DELETE ... WHERE |
| まとめて登録 | POST /_bulk |
複数行の INSERT |
# 登録・上書き
escurl -X PUT "$ES/products/_doc/1" -d '{
"name": "ワイヤレスキーボード", "description": "静かに打てる薄型のキーボード",
"category": "pc", "price": 4980, "in_stock": true, "created_at": "2026-09-01"
}'
# 1件取得
escurl "$ES/products/_doc/1?pretty"
# 一部の更新(指定したフィールドだけ変わる)
escurl -X POST "$ES/products/_update/1" -d '{ "doc": { "price": 4480 } }'
# 削除
escurl -X DELETE "$ES/products/_doc/1"
bulk でまとめて登録
_bulk は、「操作の行」と「データの行」を交互に並べた NDJSON(1行1つの JSON)を送ります。 最後の行にも改行が必要です。
cat > products.ndjson <<'EOF'
{"index":{"_index":"products","_id":"1"}}
{"name":"ワイヤレスキーボード","description":"静かに打てる薄型のキーボード","category":"pc","price":4980,"in_stock":true,"created_at":"2026-09-01"}
{"index":{"_index":"products","_id":"2"}}
{"name":"メカニカルキーボード","description":"打ち心地の良い青軸","category":"pc","price":12800,"in_stock":false,"created_at":"2026-09-15"}
{"index":{"_index":"products","_id":"3"}}
{"name":"ワイヤレスマウス","description":"静音クリックで静かに使える","category":"pc","price":2980,"in_stock":true,"created_at":"2026-10-01"}
{"index":{"_index":"products","_id":"4"}}
{"name":"USB-Cハブ","description":"サーバー室でも使える7ポート","category":"accessory","price":3980,"in_stock":true,"created_at":"2026-10-05"}
EOF
curl -s -H "Authorization: ApiKey $ES_API_KEY" -H 'Content-Type: application/x-ndjson' \
-X POST "$ES/_bulk?refresh=wait_for" --data-binary @products.ndjson
-d ではなく --data-binary を使います。-d は改行を取り除くので、NDJSON の形が崩れます。応答の "errors": true は、どれか1件でも失敗したという意味で、HTTP のステータスは 200 のままです。bulk の結果は errors と items の中の status で確かめます。
登録した直後に検索に出ない
Elasticsearch は、登録したドキュメントを一定間隔の「リフレッシュ」で検索できるようにします。 既定では1秒ごとです(index.refresh_interval)。試験やバッチで「入れてすぐ検索」したいときは、?refresh=wait_for を付けるか、POST /products/_refresh を呼びます。GET /products/_doc/1 の1件取得は、リフレッシュを待たずに読めます。
検索クエリの書き方
検索の基本形
escurl -X GET "$ES/products/_search?pretty" -d '{
"query": { "match": { "description": "静かなキーボード" } },
"_source": ["name", "price"],
"sort": [ { "price": "asc" } ],
"from": 0,
"size": 10
}'
応答の形は次のとおりです(値は省略しています)。
{
"took": 3,
"timed_out": false,
"hits": {
"total": { "value": 2, "relation": "eq" },
"max_score": null,
"hits": [
{ "_index": "products", "_id": "3", "_score": null, "_source": { "name": "…", "price": 2980 }, "sort": [2980] }
]
}
}
| 部分 | 意味 |
|---|---|
hits.total.value |
一致した件数。既定では 10,000 件までは正確(track_total_hits) |
hits.total.relation |
eq は正確な件数、gte は「それ以上」 |
hits.hits[]._score |
関連度のスコア。sort を指定すると null |
hits.hits[]._source |
登録した JSON(_source で列を絞れる) |
match:全文検索
match は、検索語をフィールドと同じアナライザーで語に分けてから探します。 既定では、分けた語のどれか1つが含まれれば一致(OR)です。
# どれかの語を含む(OR、既定)
escurl -X GET "$ES/products/_search" -d '{
"query": { "match": { "description": "静かな キーボード" } }
}'
# すべての語を含む(AND)
escurl -X GET "$ES/products/_search" -d '{
"query": { "match": { "description": { "query": "静かな キーボード", "operator": "and" } } }
}'
# 語の順番どおりに並んでいるもの(フレーズ)
escurl -X GET "$ES/products/_search" -d '{
"query": { "match_phrase": { "description": "薄型のキーボード" } }
}'
# 複数のフィールドから探す(name を2倍重く)
escurl -X GET "$ES/products/_search" -d '{
"query": { "multi_match": { "query": "キーボード", "fields": ["name^2", "description"] } }
}'
term:完全一致
term は、検索語を分けずに、そのままの値で探します。 keyword・数値・日付・真偽のフィールドに使います。
escurl -X GET "$ES/products/_search" -d '{
"query": { "term": { "category": "pc" } }
}'
# IN (...) にあたる
escurl -X GET "$ES/products/_search" -d '{
"query": { "terms": { "category": ["pc", "accessory"] } }
}'
text のフィールドに term を使うと、ほとんど一致しません。 ドキュメントでは、text のフィールドは登録時に分析されて値が変わるので、term は使わないようにと注意されています。例として、Quick Brown Foxes! は [quick, brown, fox] に変わるので、term で Quick Brown Foxes! を探しても何も返らないと説明されています。完全一致で探したい文字列は keyword 型(または .keyword のサブフィールド)にします。
| 探し方 | 使うクエリ | フィールドの型 |
|---|---|---|
| 言葉で探す | match / match_phrase / multi_match |
text |
| 値が一致 | term / terms |
keyword・数値・真偽 |
| 範囲 | range |
数値・日付 |
| 値があるか | exists |
すべて |
| 前方一致 | prefix |
keyword |
| ワイルドカード | wildcard |
keyword(遅くなりやすい) |
| あいまい(打ち間違い) | match の fuzziness / fuzzy |
text / keyword |
range:範囲
escurl -X GET "$ES/products/_search" -d '{
"query": { "range": { "price": { "gte": 1000, "lte": 5000 } } }
}'
escurl -X GET "$ES/products/_search" -d '{
"query": { "range": { "created_at": { "gte": "now-30d/d" } } }
}'
gte(以上)・gt(より大きい)・lte(以下)・lt(より小さい)を組み合わせます。日付は now-30d/d(30日前の0時)のような書き方ができます。
bool:条件の組み合わせ
bool は、複数のクエリを AND・OR・NOT で組み合わせます。
| 句 | 意味 | スコア |
|---|---|---|
must |
一致しなければならない(AND) | 加算する |
filter |
一致しなければならない(AND) | 使わない。結果がキャッシュされうる |
should |
一致するとよい(OR) | 加算する |
must_not |
一致してはならない(NOT) | 使わない。キャッシュされうる |
escurl -X GET "$ES/products/_search" -d '{
"query": {
"bool": {
"must": [ { "match": { "name": "キーボード" } } ],
"filter": [ { "term": { "category": "pc" } },
{ "range": { "price": { "gte": 1000, "lte": 10000 } } } ],
"must_not": [ { "term": { "in_stock": false } } ]
}
},
"sort": [ { "price": "asc" } ]
}'
関連度に関係しない条件(カテゴリ・価格・在庫など)は must ではなく filter に書きます。 スコアの計算をしないので軽く、キャッシュも効きます。
should の扱いには決まりがあります。ドキュメントでは、should が1つ以上あり、must も filter も無いときは minimum_should_match の既定が 1(どれか1つは一致が必要)、それ以外の場合は 0(一致しなくてもよく、スコアを上げるだけ)とされています。
# 「pc か accessory」かつ「在庫あり」:should を OR として使うなら minimum_should_match を書く
escurl -X GET "$ES/products/_search" -d '{
"query": {
"bool": {
"filter": [ { "term": { "in_stock": true } } ],
"should": [ { "term": { "category": "pc" } }, { "term": { "category": "accessory" } } ],
"minimum_should_match": 1
}
}
}'
ハイライトとページング
escurl -X GET "$ES/products/_search" -d '{
"query": { "match": { "description": "キーボード" } },
"highlight": { "fields": { "description": {} } }
}'
from + size で取れるのは、既定で 10,000 件目まで(index.max_result_window)です。それより深くたどるときは、前のページの最後の sort の値を渡す search_after を使います。
escurl -X GET "$ES/products/_search" -d '{
"size": 100,
"sort": [ { "created_at": "desc" }, { "name.raw": "asc" } ],
"search_after": ["2026-10-01T00:00:00.000Z", "ワイヤレスマウス"]
}'
集計(aggregations)
集計は aggs に書きます。 件数のバケツ(グループ)を作る「バケット集計」と、合計・平均などを出す「メトリクス集計」を入れ子にできます。ヒットしたドキュメント自体が要らなければ "size": 0 にします。
# SELECT category, COUNT(*), AVG(price) FROM products GROUP BY category
escurl -X GET "$ES/products/_search" -d '{
"size": 0,
"aggs": {
"by_category": {
"terms": { "field": "category", "size": 10 },
"aggs": { "avg_price": { "avg": { "field": "price" } } }
}
}
}'
応答では、aggregations.by_category.buckets に {"key": "pc", "doc_count": …, "avg_price": {"value": …}} の形で並びます。
| 集計 | 種類 | SQL でいうと |
|---|---|---|
terms |
バケット | GROUP BY 列(上位 size 件、既定 10) |
range |
バケット | CASE WHEN price < 1000 ... で区分 |
date_histogram |
バケット | GROUP BY date_trunc('month', created_at) |
filter / filters |
バケット | COUNT(*) FILTER (WHERE ...) |
avg / sum / min / max |
メトリクス | AVG / SUM / MIN / MAX |
stats |
メトリクス | 件数・最小・最大・平均・合計をまとめて |
cardinality |
メトリクス | COUNT(DISTINCT 列)(近似値) |
percentiles |
メトリクス | パーセンタイル(近似値) |
bucket_selector |
パイプライン | HAVING |
# 月ごとの件数と売上
escurl -X GET "$ES/products/_search" -d '{
"size": 0,
"query": { "range": { "created_at": { "gte": "2026-01-01" } } },
"aggs": {
"per_month": {
"date_histogram": { "field": "created_at", "calendar_interval": "month" },
"aggs": { "total": { "sum": { "field": "price" } } }
}
}
}'
# 価格帯ごとの件数
escurl -X GET "$ES/products/_search" -d '{
"size": 0,
"aggs": {
"price_band": {
"range": { "field": "price", "ranges": [ { "to": 3000 }, { "from": 3000, "to": 10000 }, { "from": 10000 } ] }
}
}
}'
集計できるのは keyword・数値・日付などで、text のフィールドはそのままでは terms 集計に使えません。 商品名で集計したいときは、マルチフィールドの name.raw(keyword)を指定します。
日本語検索(kuromoji)
なぜ日本語用のアナライザーが要るのか
アナライザーは、文章を検索用の語(トークン)に分ける部品です。 既定の standard アナライザーは、空白や記号で語を区切る仕組みで、空白の無い日本語の漢字・ひらがなは1文字ずつに分かれます(ドキュメントの cjk_bigram の例は、standard で1文字ずつになった「東京都は、日本の首都であり」を「東京 / 京都 / 都は …」と2文字ずつに組み直すものです)。1文字ずつでも検索はできますが、「京都」で「東京都」が当たるような、意図しない一致が増えます。
kuromoji は、辞書(MeCab-IPADIC)を使って日本語を単語に分ける、形態素解析のプラグインです。
# すべてのノードに入れて、ノードを再起動する
sudo bin/elasticsearch-plugin install analysis-kuromoji
sudo bin/elasticsearch-plugin install analysis-icu # icu_normalizer を使う場合
ドキュメントでは、プラグインはクラスターのすべてのノードに入れ、入れたあとに各ノードを再起動する必要があるとされています。
kuromoji の部品
組み込みの kuromoji アナライザーは、次の順に処理する組み合わせです。
| 順 | 部品 | すること |
|---|---|---|
| 1 | 文字フィルター(CJKWidthCharFilter) | 全角英数字・半角カナの幅をそろえる |
| 2 | kuromoji_tokenizer |
辞書を使って単語に分ける |
| 3 | kuromoji_baseform |
活用した語を基本形にする(飲み → 飲む) |
| 4 | kuromoji_part_of_speech |
助詞などの品詞を取り除く |
| 5 | ja_stop |
「これ」「の」などのよく出る語を取り除く |
| 6 | kuromoji_stemmer |
カタカナ語の末尾の長音を取る(サーバー → サーバ) |
| 7 | lowercase |
英字を小文字にする |
ドキュメントにある各部品の例です。
| 部品 | 入力 | 出力 |
|---|---|---|
kuromoji_tokenizer(search モード) |
関西国際空港 | 関西 / 関西国際空港 / 国際 / 空港 |
kuromoji_tokenizer(normal モード) |
関西国際空港 | 関西国際空港 |
kuromoji_baseform |
飲み | 飲む |
ja_stop(「ストップ」を追加) |
ストップは消える | 消える |
kuromoji_stemmer(minimum_length 4) |
コピー / サーバー | コピー / サーバ |
| ユーザー辞書を使った分割 | 東京スカイツリー | 東京 / スカイツリー |
kuromoji_stemmer は、4文字(minimum_length の既定)未満のカタカナ語は変えません。 そのため「コピー」はそのまま、「サーバー」は「サーバ」になり、「サーバ」と「サーバー」のどちらで探しても一致します。
tokenizer のモード
| mode | 長い複合語 | 例:関西国際空港 |
|---|---|---|
normal |
分けない | 関西国際空港 |
search |
分けて、元の語も残す | 関西 / 関西国際空港 / 国際 / 空港 |
extended |
search に加え、辞書に無い語を1文字ずつにする | (未知語「アブラカダブラ」→ ア / ブ / ラ / カ / ダ / ブ / ラ) |
search モードなら、「空港」で探しても「関西国際空港」が当たります。 組み込みの kuromoji アナライザーは mode と user_dictionary を設定できます。
全角・半角をそろえる(icu_normalizer)
ドキュメントでは、辞書に全角の文字が含まれているために、全角の文字で思わぬトークンが出ることがあり、それを避けるために icu_normalizer の文字フィルターを加えたアナライザーを作る方法が示されています。この記事のマッピングの ja アナライザーは、その形にしています。
{
"analyzer": {
"ja": {
"type": "custom",
"char_filter": ["icu_normalizer"],
"tokenizer": "kuromoji_tokenizer",
"filter": ["kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop", "kuromoji_stemmer", "lowercase"]
}
}
}
analysis-icu を入れない場合は、char_filter の行を除き、filter の先頭に組み込みの cjk_width(幅をそろえるトークンフィルター)を入れる形もあります。
ユーザー辞書(固有名詞・社内用語)
辞書に無い商品名や社内用語は、意図どおりに分かれないことがあります。ユーザー辞書で分け方を指定できます。
escurl -X PUT "$ES/products_ja" -d '{
"settings": {
"analysis": {
"tokenizer": {
"ja_user_dict": {
"type": "kuromoji_tokenizer",
"mode": "search",
"user_dictionary_rules": [ "東京スカイツリー,東京 スカイツリー,トウキョウ スカイツリー,カスタム名詞" ]
}
},
"analyzer": {
"ja_dict": { "type": "custom", "tokenizer": "ja_user_dict", "filter": ["kuromoji_baseform", "lowercase"] }
}
}
}
}'
1行の形は 元の語,分けた語(空白区切り),読み(空白区切り),品詞 です。ファイルにするときは config/userdict_ja.txt に置いて "user_dictionary": "userdict_ja.txt" と指定します。
分け方を確かめる(_analyze)
検索が当たらないときは、まず _analyze で、文章と検索語がどう分かれるかを見ます。 両方が同じトークンを含んでいなければ、match は当たりません。
escurl -X GET "$ES/products/_analyze?pretty" -d '{
"analyzer": "ja",
"text": "静かに打てるキーボード"
}'
# アナライザーを作らずに、部品を組み合わせて試す
escurl -X GET "$ES/_analyze?pretty" -d '{
"tokenizer": { "type": "kuromoji_tokenizer", "mode": "search" },
"filter": ["kuromoji_baseform"],
"text": "関西国際空港に飲みに行く"
}'
形態素解析で取りこぼすときは N-gram と組み合わせる
辞書で分けると、「ーボード」のような単語の途中の文字列では当たりません。部分一致も拾いたいときは、同じ列を N-gram(2文字ずつなど)でも索引に入れ、両方で探してスコアを足す方法があります。
escurl -X PUT "$ES/products_ng" -d '{
"settings": {
"analysis": {
"tokenizer": { "bigram": { "type": "ngram", "min_gram": 2, "max_gram": 2 } },
"analyzer": {
"ja": { "type": "custom", "tokenizer": "kuromoji_tokenizer",
"filter": ["cjk_width", "kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop", "kuromoji_stemmer", "lowercase"] },
"ja_bigram": { "type": "custom", "tokenizer": "bigram", "filter": ["cjk_width", "lowercase"] }
}
}
},
"mappings": {
"properties": {
"name": { "type": "text", "analyzer": "ja", "fields": { "ngram": { "type": "text", "analyzer": "ja_bigram" } } }
}
}
}'
escurl -X GET "$ES/products_ng/_search" -d '{
"query": { "multi_match": { "query": "ボード", "fields": ["name^2", "name.ngram"] } }
}'
name が kuromoji、name.ngram が2文字ずつの分割です。name^2 で kuromoji の一致を重くしているので、kuromoji で当たるものが上に、N-gram だけで当たるものが下に並びます。この例の ja は、analysis-icu を使わず、組み込みの cjk_width で幅をそろえる形にしています。PostgreSQL での日本語の部分一致検索は、関連記事「PostgreSQL日本語全文検索|pg_trgmとpg_bigmの速度比較と選び方」で扱っています。
SQL との対応表
| SQL | Elasticsearch |
|---|---|
CREATE TABLE |
PUT /products(mappings) |
ALTER TABLE ADD COLUMN |
PUT /products/_mapping(型の変更は不可) |
INSERT |
PUT /products/_doc/1・POST /_bulk |
SELECT * WHERE id = 1 |
GET /products/_doc/1 |
UPDATE SET price = 4480 WHERE id = 1 |
POST /products/_update/1 + {"doc": {...}} |
DELETE WHERE id = 1 |
DELETE /products/_doc/1 |
WHERE category = 'pc' |
term(filter の中) |
WHERE category IN ('pc', 'acc') |
terms |
WHERE price BETWEEN 1000 AND 5000 |
range(gte・lte) |
WHERE maker IS NOT NULL |
exists |
WHERE name LIKE 'キー%' |
prefix(keyword 型) |
WHERE description LIKE '%静か%' |
match(意味は違う:語単位の一致) |
AND / OR / NOT |
bool の must・filter / should / must_not |
ORDER BY price |
sort |
LIMIT 10 OFFSET 20 |
size: 10, from: 20 |
COUNT(*) |
GET /products/_count・hits.total |
GROUP BY category |
terms 集計 |
COUNT(DISTINCT maker) |
cardinality 集計(近似値) |
HAVING |
bucket_selector |
JOIN |
基本は無い。1つのドキュメントにまとめて入れる(非正規化)か nested |
| トランザクション | 無い(1ドキュメント単位の操作) |
LIKE '%静か%' と match は同じではありません。 match は「アナライザーで分けた語が一致するか」で探し、関連度の順に並べます。文字列のどこかに含まれるかを調べる LIKE とは考え方が違います。ドキュメント型のデータベースと SQL の対応は、関連記事「MongoDB入門|mongoshのコマンド一覧とSQLとの対応表」で扱っています。
Elasticsearch には SQL で問い合わせる機能もあり、POST /_sql/translate で SQL を Query DSL に変換した結果を見られます。DSL の書き方を調べるときに使えます。
escurl -X POST "$ES/_sql/translate" -d '{
"query": "SELECT name, price FROM products WHERE category = '\''pc'\'' ORDER BY price DESC LIMIT 10"
}'
Python のコード(全文)
公式の Python クライアントで、ここまでの操作を1つにまとめたコードです。
import os
from elasticsearch import Elasticsearch, helpers
es = Elasticsearch(
os.environ.get("ES_URL", "https://localhost:9200"),
api_key=os.environ.get("ES_API_KEY"), # または basic_auth=("elastic", "パスワード")
ca_certs=os.environ.get("ES_CA_CERTS"), # 自己署名の証明書なら http_ca.crt のパス
)
INDEX = "products"
# 1. インデックスを作り直す(マッピングと日本語のアナライザー)
es.indices.delete(index=INDEX, ignore_unavailable=True)
es.indices.create(
index=INDEX,
settings={
"number_of_shards": 1,
"analysis": {
"analyzer": {
"ja": {
"type": "custom",
"char_filter": ["icu_normalizer"],
"tokenizer": "kuromoji_tokenizer",
"filter": ["kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop",
"kuromoji_stemmer", "lowercase"],
}
}
},
},
mappings={
"properties": {
"name": {"type": "text", "analyzer": "ja", "fields": {"raw": {"type": "keyword"}}},
"description": {"type": "text", "analyzer": "ja"},
"category": {"type": "keyword"},
"price": {"type": "integer"},
"in_stock": {"type": "boolean"},
"created_at": {"type": "date"},
}
},
)
# 2. まとめて登録(bulk)
docs = [
{"_id": "1", "name": "ワイヤレスキーボード", "description": "静かに打てる薄型のキーボード", "category": "pc", "price": 4980, "in_stock": True, "created_at": "2026-09-01"},
{"_id": "2", "name": "メカニカルキーボード", "description": "打ち心地の良い青軸", "category": "pc", "price": 12800, "in_stock": False, "created_at": "2026-09-15"},
{"_id": "3", "name": "ワイヤレスマウス", "description": "静音クリックで静かに使える", "category": "pc", "price": 2980, "in_stock": True, "created_at": "2026-10-01"},
{"_id": "4", "name": "USB-Cハブ", "description": "サーバー室でも使える7ポート", "category": "accessory", "price": 3980, "in_stock": True, "created_at": "2026-10-05"},
]
ok, errors = helpers.bulk(es, ({"_index": INDEX, **d} for d in docs), refresh="wait_for")
print("bulk:", ok, errors)
# 3. 1件の取得・更新・削除
print(es.get(index=INDEX, id="1")["_source"])
es.update(index=INDEX, id="1", doc={"price": 4480})
es.delete(index=INDEX, id="4")
# 4. 全文検索(match)
r = es.search(index=INDEX, query={"match": {"description": "静かなキーボード"}})
for h in r["hits"]["hits"]:
print(h["_score"], h["_source"]["name"])
# 5. 条件の組み合わせ(bool)とページング
r = es.search(
index=INDEX,
query={
"bool": {
"must": [{"match": {"name": "キーボード"}}],
"filter": [{"term": {"category": "pc"}},
{"range": {"price": {"gte": 1000, "lte": 10000}}}],
"must_not": [{"term": {"in_stock": False}}],
}
},
sort=[{"price": "asc"}],
from_=0, size=10,
source=["name", "price"],
)
print("total:", r["hits"]["total"]["value"])
# 6. 集計(GROUP BY category, AVG(price))
r = es.search(
index=INDEX, size=0,
aggs={"by_category": {"terms": {"field": "category"},
"aggs": {"avg_price": {"avg": {"field": "price"}}}}},
)
for b in r["aggregations"].get("by_category", {}).get("buckets", []):
print(b["key"], b["doc_count"], b["avg_price"]["value"])
# 7. アナライザーの確認
r = es.indices.analyze(index=INDEX, analyzer="ja", text="静かに打てるキーボード")
print([t["token"] for t in r["tokens"]])
Python のコードが送る HTTP リクエスト
このコードを実行し、擬似サーバーで受けたリクエストです。Python の引数が、curl の例と同じ URL・JSON になっていることが分かります。
| Python | メソッドと URL | 本文(抜粋) |
|---|---|---|
indices.delete(..., ignore_unavailable=True) |
DELETE /products?ignore_unavailable=true |
なし |
indices.create(settings=..., mappings=...) |
PUT /products |
{"mappings":{...},"settings":{...}} |
helpers.bulk(..., refresh="wait_for") |
PUT /_bulk?refresh=wait_for |
{"index":{"_id":"1","_index":"products"}} と JSON の行が交互 |
get(index=..., id="1") |
GET /products/_doc/1 |
なし |
update(..., doc={"price": 4480}) |
POST /products/_update/1 |
{"doc":{"price":4480}} |
delete(index=..., id="4") |
DELETE /products/_doc/4 |
なし |
search(query=..., sort=..., from_=0, size=10, source=[...]) |
POST /products/_search |
{"from":0,"query":{...},"size":10,"sort":[...],"_source":["name","price"]} |
search(size=0, aggs=...) |
POST /products/_search |
{"aggs":{...},"size":0} |
indices.analyze(...) |
POST /products/_analyze |
{"analyzer":"ja","text":"静かに打てるキーボード"} |
Python では from が予約語なので from_ と書き、_source は source と書きます。 送られる JSON では from・_source に変わっていました。bulk のリクエストの Content-Type は application/vnd.elasticsearch+x-ndjson; compatible-with=9 で、すべてのリクエストに Authorization ヘッダーが付いていました。
よくあるつまずき
| 症状 | 原因 | 直し方 |
|---|---|---|
| 入れたのに検索で出ない | リフレッシュ前(既定 1秒間隔) | ?refresh=wait_for、_refresh |
term で文字列が当たらない |
text 型は分析されている |
keyword 型か .keyword で term |
terms 集計で text のフィールドがエラー |
text は集計に使えない |
keyword のマルチフィールドを指定 |
| 日本語で関係ない結果が多い | standard アナライザーで1文字ずつ |
kuromoji のアナライザーをマッピングに設定 |
| 型を変えたい | 既存のフィールドの型は変更できない | 新しいインデックスを作って _reindex |
| 10,000 件目より後が取れない | index.max_result_window(既定 10,000) |
search_after |
hits.total.value が 10000 で止まる |
track_total_hits の既定 |
"track_total_hits": true(重くなる) |
| bulk が 200 なのに一部入っていない | 1件ごとの失敗は items に入る |
errors と各 status を確かめる |
よくある質問
Q. Elasticsearch と OpenSearch のどちらを使えばよいですか。 A. OpenSearch は、Elasticsearch 7.10 をもとに分かれたプロジェクトで、この記事の基本の API(インデックス・CRUD・match・term・bool・集計)は同じ形で書けます。日本語の解析も、OpenSearch では analysis-kuromoji のプラグインで行います。AWS の OpenSearch Service を使うか、Elastic のサービスを使うかで決まることが多く、Python では OpenSearch 用のクライアント(opensearch-py)を使います。
Q. RDB の代わりに使えますか。 A. 向いていません。トランザクションや JOIN が無く、登録してから検索できるまで少し間があります。RDB をデータの正本にして、検索に必要な列を Elasticsearch にコピーする構成が一般的です。
Q. シャードの数はいくつにすればよいですか。
A. シャードは、インデックスを分けて持つ単位です。主シャードの数はあとから変えられない(分割・縮小の API はある)ので、データ量とノードの数から決めます。小さなインデックス(この記事の例など)なら number_of_shards: 1 で始め、データが増えてから見直す方法があります。
まとめ
- インデックス = 表、ドキュメント = 行、マッピング = 表の定義。操作はすべて REST API の JSON
- 文字列は
text(全文検索)とkeyword(完全一致・並べ替え・集計) を使い分ける。両方要るならマルチフィールド - 検索は
match(言葉で探す)・term(値が一致)・range・bool。スコアに関係ない条件はfilterに書く textのフィールドにtermを使わない- 集計は
aggs。termsが GROUP BY、avg・sumなどを入れ子にする - 日本語は kuromoji(analysis-kuromoji プラグイン)。当たらないときは
_analyzeで分け方を見る - 型は変えられないので、変えるときは 新しいインデックス +
_reindex - Python クライアントでは
from_・sourceと書く
参考・出典
確認日はいずれも 2026年10月13日です。
- Elastic Docs「Run Elasticsearch locally」:https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart
- Elastic Docs「Dynamic field mapping」:https://www.elastic.co/docs/manage-data/data-store/mapping/dynamic-field-mapping
- Elastic Docs「Match query」:https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-match-query
- Elastic Docs「Term query」:https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-term-query
- Elastic Docs「Boolean query」:https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-bool-query
- Elastic Docs「SQL Translate API」:https://www.elastic.co/docs/reference/query-languages/sql/sql-translate
- Elastic Docs「Japanese (kuromoji) analysis plugin」:https://www.elastic.co/docs/reference/elasticsearch/plugins/analysis-kuromoji
- Elastic Docs「kuromoji analyzer」:https://www.elastic.co/docs/reference/elasticsearch/plugins/analysis-kuromoji-analyzer
- Elastic Docs「kuromoji_tokenizer」:https://www.elastic.co/docs/reference/elasticsearch/plugins/analysis-kuromoji-tokenizer
- Elastic Docs「kuromoji_baseform」「ja_stop」「kuromoji_stemmer」:https://www.elastic.co/docs/reference/elasticsearch/plugins/analysis-kuromoji-baseform ・ https://www.elastic.co/docs/reference/elasticsearch/plugins/analysis-kuromoji-stop ・ https://www.elastic.co/docs/reference/elasticsearch/plugins/analysis-kuromoji-stemmer
- Elastic Docs「Python client examples」:https://www.elastic.co/docs/reference/elasticsearch/clients/python/examples
- Elasticsearch のソース「DynamicFieldsBuilder.java」:https://github.com/elastic/elasticsearch/blob/main/server/src/main/java/org/elasticsearch/index/mapper/DynamicFieldsBuilder.java
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Elasticsearch入門|インデックスと検索クエリの書き方・日本語検索」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。