1. 前書き
こんにちは。
前回に引き続き、ホワイトペーパー「Elasticsearchを使った簡易RAGアプリケーションの作成」に
記載した技術的要素を紹介いたします。(*脚注11)
今回は N-gram 検索です。
前半(Part 1)と後半(Part 2)の2回に分けて紹介します。
前半は N-gram 検索の基本的な考え方について、後半は実践的なサンプルアプリについて紹介します。
なお、今回使用した Elasticsearch のリクエストは、下記の GitHub リポジトリで公開しています。
対象者
- Elasticsearch の初心者~中級者
できるようになること
- Elasticsearch で N-gram を使った検索を行えるようになる。
前提条件
- Elasticsearch (クラウド版でもオンプレミス版でも可。筆者は、version: 8.17.3 のオンプレミス版で動作確認しました)
- Elasticsearch に ICU analysis のプラグインを追加インストールしていること。(*脚注22)
- Elasticsearch に kuromoji analysis のプラグインを追加インストールしていること。(*脚注33)
なお、今回のサンプルではベクトル検索は行いません。
(2025年04月09日時点の情報を元に記載しています。)
2. N-gram検索とは?
Elasticsearch で日本語の形態素解析を行う場合、一般的には kuromoji を使って形態素解析が行われます。
その際 kuromoji が知らない単語 (*1) が含まれていた場合、正しく形態素解析が行われないことがあります。
(*1) 専門用語、社内用語、最新の単語、略語など。
「タイパ」を含む例を考えます。
GET /kakinosuke/_analyze
{
"analyzer": "ja_kuromoji_search_analyzer",
"text": "最近の若者はタイパを⾮常に気にしているようです。"
}
この解析結果は下記のようになります。
...
"token": "タイ",
...
"token": "パ",
...
“タイパ” が “タイ” と “パ” に分割されて解析されてしまっています。
これは、kuromoji が “タイパ” という単語を知らないためです。
このような場合の1つの対策は、Elasticsearchでのユーザー辞書登録を利用した検索
で紹介したユーザー辞書登録を行うことですが、ユーザー辞書登録は頻繁にできることではありません。ユーザー辞書登録を行うまでは検索できなくなります。
このような単語(kuromoji で正しく形態素解析が行われないような単語)を多く検索するような
場合には、N-gram検索を行うことも一つの解決方法になります。
日本語は、2~3文字程度の単語になることが多いので、あらかじめ 2~3文字に区切って保管しておき、それらを使って検索することができます。
N-gram (N=2) の場合、
「最近の若者はタイパを⾮常に気にしているようです。」
は、次のように分解されます。
登録される文字列 |
---|
最近 |
近の |
の若 |
若者 |
者は |
はタ |
タイ |
イパ |
パを |
を非 |
… |
3. N-gram検索のメリットとデメリット
N-gram検索を行う場合のメリットとデメリットは、次の通りです。
メリット
- ユーザー辞書登録を行わなくても、kuromoji が正しく認識できない単語を検索できる可能性が高まる。
デメリット
- ノイズとして、関係ないドキュメントがヒットする場合がある。
- N-gram用の保管領域を消費する。
4. N-gram の適切な N値

によれば、まずは N=2 で試してみて、それでいいパフォーマンスが得られない場合には、 N=3 を試してみるといった手法がいいようです。
今回のブログでも N=2 のケースを取り上げます。
※なお、N=2 の N-gram のことを bi-gram (バイグラム)、
N=3 の N-gram のことを tri-gram (トリグラム)と呼びます。
5. 検証用インデックスの作成
以下のインデックスを検証用に作成します。
(あくまでも必要最低限の動作検証のため、同義語およびユーザー辞書は空としておきます。)
インデックス作成リクエスト
PUT /ngram_sample_202504
{
"settings": {
"index": {
"number_of_shards": 1,
"number_of_replicas": 1,
"refresh_interval": "3600s"
},
"analysis": {
"char_filter": {
"ja_normalizer": {
"type": "icu_normalizer",
"name": "nfkc",
"mode": "compose"
}
},
"tokenizer": {
"ja_kuromoji_tokenizer": {
"mode": "search",
"type": "kuromoji_tokenizer",
"discard_compound_token": true,
"user_dictionary_rules": [
]
},
"ja_ngram_tokenizer": {
"type": "ngram",
"min_gram": 2,
"max_gram": 2,
"token_chars": [
"letter",
"digit"
]
}
},
"analyzer": {
"ja_kuromoji_index_analyzer": {
"type": "custom",
"char_filter": [
"ja_normalizer",
"kuromoji_iteration_mark"
],
"tokenizer": "ja_kuromoji_tokenizer",
"filter": [
"kuromoji_baseform",
"kuromoji_part_of_speech",
"cjk_width",
"ja_stop",
"kuromoji_number",
"kuromoji_stemmer"
]
},
"ja_ngram_index_analyzer": {
"type": "custom",
"char_filter": [
"ja_normalizer"
],
"tokenizer": "ja_ngram_tokenizer",
"filter": [
"lowercase"
]
}
}
}
},
"mappings": {
"properties": {
"chunk_no": {
"type": "long"
},
"content": {
"type": "text",
"analyzer": "ja_kuromoji_index_analyzer",
"search_analyzer": "ja_kuromoji_index_analyzer",
"fields": {
"ngram": {
"type": "text",
"analyzer": "ja_ngram_index_analyzer",
"search_analyzer": "ja_ngram_index_analyzer"
}
}
}
}
}
}
今回は、ngram を min=2, max=2 で作成します。(ja_ngram_tokenizer に設定します。)
content フィールドに本文を格納し、content.ngram フィールドに N-gram 用に2文字ずつ区切った文字列を格納します。
6. 検証用ドキュメントの登録
検証用に以下のドキュメントを登録します。
POST /ngram_sample_202504/_doc
{ "chunk_no": 1, "content": "最近は、タイパを重視して行動する人が多くなった。" }
POST /ngram_sample_202504/_doc
{ "chunk_no": 2, "content": "タイムズスクエア近くでパーティーがあった。" }
POST /ngram_sample_202504/_doc
{ "chunk_no": 3, "content": "タイムズスクエア近くで何かのパフォーマンスが行われた。" }
POST /ngram_sample_202504/_doc
{ "chunk_no": 4, "content": "タイには、なんとか「パ」というお店があるとか、ないとか。" }
POST /ngram_sample_202504/_doc
{ "chunk_no": 5, "content": "タイには、「パ」で始まる名前が多いとか、少ないとか。" }
POST /ngram_sample_202504/_doc
{ "chunk_no": 6, "content": "タイのパなんとかという地域に、珍しい食べ物があったとか、なかったとか。。" }
POST /ngram_sample_202504/_refresh
7. N-gramを利用しない検索
まずは、N-gramを考慮せずに検索してみます。
GET /ngram_sample_202504/_search
{
"query": {
"match": {
"content": "タイパ"
}
}
}
レスポンス
{
...,
"hits": {
"total": {
"value": 4,
"relation": "eq"
},
"max_score": 1.2778894,
"hits": [
{
...,
"_source": {
"chunk_no": 4,
"content": "タイには、なんとか「パ」というお店があるとか、ないとか。"
}
},
{
...,
"_source": {
"chunk_no": 5,
"content": "タイには、「パ」で始まる名前が多いとか、少ないとか。"
}
},
{
,,,,
"_source": {
"chunk_no": 6,
"content": "タイのパなんとかという地域に、珍しい食べ物があったとか、なかったとか。。"
}
},
{
...,
"_source": {
"chunk_no": 1,
"content": "最近は、タイパを重視して行動する人が多くなった。"
}
}
]
}
}
「タイパ」を含むドキュメントもヒットしてはいますが、それよりも高いスコアで「タイパ」を含まないドキュメントがヒットしてしまっています。
これは、前述のとおり、「タイ」と「パ」それぞれで検索が行われているためです。(*脚注44)
8. N-gramを利用した検索
N-gramだけを検索してもいいのですが、Elasticsearch の強力な検索機能を使って
content フィールドと content.ngram フィールドの両方を検索してみます。
その際、content.ngram の方でマッチしたものを重視するよう _score を5倍にします。
GET /ngram_sample_202504/_search
{
"query": {
"multi_match": {
"fields": [ "content.ngram^5", "content" ],
"query": "タイパ"
}
}
}
レスポンス
{
...,
"hits": {
"total": {
"value": 6,
"relation": "eq"
},
"max_score": 6.310552,
"hits": [
{
...,
"_source": {
"chunk_no": 1,
"content": "最近は、タイパを重視して行動する人が多くなった。"
}
},
{
...,
"_source": {
"chunk_no": 4,
"content": "タイには、なんとか「パ」というお店があるとか、ないとか。"
}
},
{
...,
"_source": {
"chunk_no": 5,
"content": "タイには、「パ」で始まる名前が多いとか、少ないとか。"
}
},
{
...,
"_source": {
"chunk_no": 6,
"content": "タイのパなんとかという地域に、珍しい食べ物があったとか、なかったとか。。"
}
},
{
...,
"_source": {
"chunk_no": 2,
"content": "タイムズスクエア近くでパーティーがあった。"
}
},
{
...,
"_source": {
"chunk_no": 3,
"content": "タイムズスクエア近くで何かのパフォーマンスが行われた。"
}
}
]
}
}
「タイパ」を含むドキュメントが上位に来るようになりました。
しかし、「タイパ」を含まないものも、下位ではありますが返却されています。
“min_score” を指定することで、これらを除去することができます。
GET /ngram_sample_202504/_search
{
"query": {
"multi_match": {
"fields": [ "content.ngram^5", "content" ],
"query": "タイパ"
}
},
"min_score": 5
}
レスポンス
{
...,
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 6.310552,
"hits": [
{
...,
"_source": {
"chunk_no": 1,
"content": "最近は、タイパを重視して行動する人が多くなった。"
}
}
]
}
}
これで、「タイパ」を含むもののみが返却されるようになりました。
ただし、min_scoreにあまりにも高い数値を設定してしまうと、本来はマッチしているドキュメントまでも返却されなくなる恐れがあるので注意が必要です。
※2025-04-11 追記

のブログによれば、N-gram 部分は、match_phrase で検索するのがよいようです。前述の検索クエリーを “match_phrase” を利用するように書き換えてみたものがこちらになります。
GET /ngram_sample_202504/_search
{
"query": {
"bool": {
"should": [
{
"match_phrase": {
"content.ngram": {
"query": "タイパ",
"boost": 5
}
}
},
{
"match": {
"content": "タイパ"
}
}
]
}
},
"min_score": 5
}
上記は、あくまでもサンプルです。検索対象のドキュメントの内容や、検索する文字列(キーワード)によって、どういう検索を行うのがベストか? は変わってくると思います。
9. N-gram検索の問題
さきほど、【3. N-gram検索のメリットとデメリット】で、ノイズを拾いやすくなるデメリットがある、と書きました。
わかりやすい例を挙げたいと思います。
例えば、インデックスに次のドキュメントがあったとします。
POST /ngram_sample_202504/_doc?refresh=true
{ "chunk_no": 7, "content": "あの通りの向こうに、ムエタイパークがあるとか、ないとか。" }
これで、再度、下記の検索を行います。
GET /ngram_sample_202504/_search
{
"query": {
"multi_match": {
"fields": [ "content.ngram^5", "content" ],
"query": "タイパ"
}
},
"min_score": 5
}
レスポンス
{
...,
"hits": {
"total": {
"value": 2,
"relation": "eq"
},
"max_score": 6.310552,
"hits": [
{
...,
"_source": {
"chunk_no": 1,
"content": "最近は、タイパを重視して行動する人が多くなった。"
}
},
{
...,
"_source": {
"chunk_no": 7,
"content": "あの通りの向こうに、ムエタイパークがあるとか、ないとか。"
}
}
]
}
}
ムエタイパークを含むドキュメントがヒットしてしまいます。
これは、「ムエ『タイパ』ーク」の中に「タイパ」が含まれているためです。
このように、N-gram検索では、人間が見れば無関係と思われるドキュメントもヒットしてしまうデメリットもあります。
10. まとめ
N-gram検索を利用することで、ユーザー辞書登録を行わなくても、kuromoji が認識できない単語を含むドキュメントを検索できるようになりました。
次回は、N-gram 検索を利用したサンプルアプリを紹介する予定です。
- ホワイトペーパー「Elasticsearchを使った簡易RAGアプリケーションの作成」は、下記からダウンロードできます。(E-mailアドレスなどの入力が必要です。)
https://elastic.sios.jp/whitepaper/
↩︎ - 下記に ICU analysis plugin のインストール方法が記載されています。
https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html
↩︎ - 下記に kuromoji analysis plugin のインストール方法が記載されています。
https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html
↩︎ - 「タイパ」だけを検索したいのであれば、”match_phrase” で検索することも可能です。
しかし、「タイパ」と別の何かを検索したい場合、”match_phrase” で検索しようとすると、
クエリーが複雑になってしまうデメリットがあります。
↩︎