Elasticsearchでのユーザー辞書登録を利用した検索

BLOG

1. 前書き

こんにちは。

前回に引き続き、ホワイトペーパー「Elasticsearchを使った簡易RAGアプリケーションの作成」(*脚注11)に記載した技術的要素を紹介いたします。

今回は、ユーザー辞書登録についてです。

対象者

  • Elastic Cloud のアカウントを持っている人(トライアルライセンスを含む)
  • Elasticsearch の初心者~中級者

できるようになること

  • Elasticsearch のユーザー辞書への登録ができるようになる。
  • Elasticsearch でユーザー辞書に登録された単語を含む検索ができるようになる。

前提条件

  • Elastic Cloud (version: 8.17.3)
  • Elasticsearch で日本語の形態素解析の設定を行っていること

日本語に適したインデックスの作成 の回で、日本語の形態素解析の設定方法を紹介しています。

(2025年04月02日時点の情報を元に記載しています。)

2. ユーザー辞書とは?

Elasticsearch で日本語の検索を行う場合、一般的には kuromoji を使って形態素解析を行います。(*脚注22)

しかし、kuromoji が知らない単語が含まれている場合、適切に形態素解析が行われないことがあります。

例)

  • 社内の独自用語など。弊社の社名「サイオス」は、kuromoji が知らないため、「サイ」と「オス」に分割されてしまいます。
  • kuromoji が作られた時点では存在しなかった、あるいは一般的ではなかった単語。「クラウド」「タイパ」など。

このような単語を扱う場合、ユーザー辞書に登録することで、適切な形態素解析が行われ、その後の日本語での検索が正しく行われるようになります。

3. ユーザー辞書登録前の動作確認

前回のElasticsearchでの検索結果のハイライト表示では「柿之助」を検索したところ、「柿」もヒットしてしまいました。
これは、「柿之助」という単語を kuromoji が知らないため、「柿」と「助」に分解されていたことによるものです。

念のため、analyzer による動作を確認してみます。

下記のリクエストを Elastic Cloud の Console から実行してみます。

GET /kakinosuke/_analyze
{
  "analyzer": "ja_kuromoji_search_analyzer",
  "text": "柿之助"
}

※注 “ja_kuromoji_search_analyzer” は、 日本語に適したインデックスの作成 の回で登録した日本語用のアナライザーです。

レスポンス

{
  "tokens": [
    {
      "token": "柿",
      "start_offset": 0,
      "end_offset": 1,
      "type": "word",
      "position": 0
    },
    {
      "token": "助",
      "start_offset": 2,
      "end_offset": 3,
      "type": "word",
      "position": 2
    }
  ]
}

「柿之助」が「柿」と「助」に分解されてしまっていることがわかります。
(「之」は接続詞とみなされ除去されています。)

念のため、”柿之助” で検索した場合の検索結果を確認しておきます。

GET /kakinosuke/_search
{
  "query": {
    "match": {
      "content": "柿之助"
    }
  },
  "size": 30
}

レスポンス

...
  "hits": {
    "total": {
      "value": 29,
      "relation": "eq"
    },
    ...
    "_source": {
      "chunk_no": 4,
      ...,
      "content": "...さっきの柿を...この柿を。...みごとな柿を..."
    },
    ...
  }

全体で29件ヒットして、そのうち、数件は「柿」のみにマッチしたドキュメントが返却されていることがわかります。

4. ユーザー辞書登録を行ったインデックスの作成

Bulk API (Pythonからのドキュメントの一括登録) の回で作成した kakinosuke_202503 インデックスではユーザー辞書登録を行っていませんでした。

今回は、kakinosuke_202503 インデックスとは別に、kakunosuke_202504 インデックスを作成し、ユーザー辞書登録を行います。(*脚注33)

インデックス名ユーザー辞書
kakinosuke_202503登録なし
kakinosuke_202504“柿之助”を登録

kakinosuke_202504 インデックスの作成リクエスト

PUT /kakinosuke_202504
{
  "settings": {
    "index": {
      "number_of_shards": 1,
      "number_of_replicas": 1,
      "refresh_interval": "3600s"
    },
    "analysis": {
      "char_filter": {
        "ja_normalizer": {
          "type": "icu_normalizer",
          "name": "nfkc_cf",
          "mode": "compose"
        }
      },
      "tokenizer": {
        "ja_kuromoji_tokenizer": {
          "mode": "search",
          "type": "kuromoji_tokenizer",
          "discard_compound_token": true,
          "user_dictionary_rules": [
            "柿之助,柿之助,カキノスケ,カスタム名詞"
          ]
        }
      },
      "filter": {
        "ja_search_synonym": {
          "type": "synonym_graph",
          "synonyms_set": "kakinosuke_synonyms_set",
          "updateable": true
        }
      },
      "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_kuromoji_search_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_search_synonym"
          ]
        }
      }
    }
  },
  "mappings": {
    "dynamic": false,
    "_source": {
      "excludes": [
        "text_embedding.*"
      ]
    },
    "properties": {
      "chunk_no": {
        "type": "integer"
      },
      "content": {
        "type": "text",
        "analyzer": "ja_kuromoji_index_analyzer",
        "search_analyzer": "ja_kuromoji_search_analyzer"
      },
      "text_embedding": {
        "properties": {
          "model_id": {
            "type": "keyword",
            "ignore_above": 256
          },
          "predicted_value": {
            "type": "dense_vector",
            "dims": 384
          }
        }
      }
    }
  }
}

user_dictionary_rules がユーザー辞書登録の部分になります。

user_dictionary_rules に「柿之助」を登録します。

試しに、analyze を行ってみます。

下記のリクエストを Elastic Cloud の Console から実行してみます。

GET /kakinosuke_202504/_analyze
{
  "analyzer": "ja_kuromoji_search_analyzer",
  "text": "柿之助"
}

レスポンス

{
  "tokens": [
    {
      "token": "柿之助",
      "start_offset": 0,
      "end_offset": 3,
      "type": "word",
      "position": 0
    }
  ]
}

「柿之助」が、「柿」と「助」に分解されずに、「柿之助」のままとなっています。

5. Reindex API の実行

Reindex API を使って、既存の kakinosuke_202503 インデックスから kakinosuke_202504 インデックスへドキュメントをコピーします。

POST _reindex?refresh=true
{
  "source": {
    "index": "kakinosuke_202503",
    "_source": [ "chunk_no", "content" ]
  },
  "dest": {
    "index": "kakinosuke_202504",
    "pipeline": "japanese-text-embeddings"
  }
}

Reindex を行う際に、以下の指定を行っています。

  • “refresh=true”
    • Reindex 終了後に refresh を行います。
  • “source”: { “index”: “kakinosuke_202503”, … }
    • コピー元のインデックスが “kakunosuke_202503” であることを指定します。
  • “_source” : [ “chunk_no”, “content” ]
    • コピー対象のフィールド名を明記しておきます。すべてコピーする場合には省略可能ですが、今回はわかりやすいよう、あえて明記しています。
  • “dest”: { “index”: “kakinosuke_202504”, … }
    • コピー先のインデックスが “kakunosuke_202504” であることを指定します。
  • “pipeline”: “japanese-text-embeddings”
    • 文字列を再登録する際に、ベクトルを生成します。コピー元の kakinosuke_202501 インデックスでは、密ベクトルを _source に保持していないのでコピーすることができません。Reindex 実行時に、再度、pipeline を通すことで、密ベクトルを再作成します。

※ “japanese-text-embeddings” パイプラインは、ベクトル検索の準備 の回で登録した、密ベクトル生成用のパイプラインです。

※ドキュメントを再度登録しないとユーザー辞書の内容が反映されないのが欠点です。
データ量が多いと、Reindex の完了には時間がかかります。
ですので、ユーザー辞書への登録タイミングは慎重に検討してください。
頻繁に行うのは、好ましくありません。

ユーザー辞書への登録が反映されているかどうか、確認してみます。

GET /kakinosuke_202504/_search
{
  "query": {
    "match": {
      "content": "柿之助"
    }
  },
  "size": 30
}

レスポンス

  "hits": {
    "total": {
      "value": 23,
      "relation": "eq"
    },
    ...
    "_source": {
      "chunk_no": ...,
      ...,
      "content": "...柿之助..."
    },
    ...
  }

さきほど29件ヒットしていたものが23件になっています。
また、「柿之助」にヒットしていますが、「柿」のみヒットしている箇所がないことがわかります。

6. エイリアスの再作成

エイリアス kakinosuke を再作成します。

変更前の参照先変更後の参照先
kakinosuke_202503kakinosuke_202504

下記のリクエストを Elastic Cloud の Console から実行します。

古いエイリアスを削除して、新しいエイリアスを作成します。

POST _aliases
{
  "actions": [
    {
      "remove": {
        "index": "kakinosuke_202503",
        "alias": "kakinosuke"
      }
    },
    {
      "add": {
        "index": "kakinosuke_202504",
        "alias": "kakinosuke"
      }
    }
  ]
}

7. 検索アプリの実行

前回の検索アプリを実行してみます。

Streamlit で作成した検索アプリに

  • ハイライト表示する:「はい」
  • 検索クエリ:「柿之助 おばあさん」

を入力して、【検索】ボタンを押すと、次のような画面が表示されます。

「柿之助」と「おばあさん」のみが強調されていることがわかります。「柿」のみの箇所は強調されていません。

8. まとめ

ユーザー辞書を登録することで、kuromoji が知らない単語を含む文章も適切に形態素解析が行われ、検索できるようになりました。この手法を用いて RAG での検索結果を改善し、最終的な回答の精度向上につながることもあります。

次回は、同義語について説明する予定です。


  1. ホワイトペーパー「Elasticsearchを使った簡易RAGアプリケーションの作成」は、下記よりダウンロード可能です(E-mailアドレスなどの入力が必要です)。
    https://elastic.sios.jp/whitepaper/
    ↩︎
  2. Elasticsearch が利用している kuromoji は、やや古い辞書を参照しています。このため、最近使われるようになった単語を知らない場合があります。
    例)「クラウド」 → 「くら(蔵)」+「うど」に分解されます。
    この問題に対応するため、kuromoji よりも新しい形態素解析エンジンを使う方法もあります。
    例)sudachi
    ただし、”sudachi” が知らない単語(社内用語や専門用語など)を形態素解析したい場合は、やはりユーザー辞書登録するなどの対策が必要となります。
    ↩︎
  3. ※別のインデックスを作成せずに、元のインデックスのままユーザー辞書を登録する方法もあります。
    その場合は、
    – 1. index_name/_close
    – 2. ユーザー辞書登録
    – 3. index_name/_open
    – 4. index_name/_update_by_query
    を行う必要があります。
    ベクトルの再作成は必要ありません。その分、全体的な処理時間は短く済む可能性があります。
    また、データ容量として1つ分のインデックスで済むので、(一時的であっても)2つのインデックスを保持する方法より少なくて済みます。
    ただし、前述の手順の 1~4 の間、元のインデックスは利用できなくなります。(検索サービスを停止するなどの措置が必要です。)
    また、ユーザー辞書登録に問題が見つかってやり直したい場合、
    – 事前に取得しておいた Snapshot から Restore する。
    あるいは
    – ユーザー辞書を修正してから再度 _update_by_query を実行する(上記の 1~4 をやり直す)。
    などの復旧策が必要となります。
    いずれにしても、その間、元のインデックスは使えなくなります。サービスを一定時間停止してもいいような場合では、こちらの方法を採用するのもいいかもしれません。
    なお、2つのインデックスを使う方法であれば、エイリアスを切り替えるだけで元に戻せます(ほんの一瞬です)。
    ↩︎