Elasticsearchで同義語（Synonym）を使う時の注意点

この記事は [レコチョク Advent Calendar 2024](https://qiita.com/advent-calendar/2024/recochoku) の9日目の記事となります。 

## はじめに
こんにちは、株式会社レコチョク NX開発推進部 次世代プロダクト開発グループの山本です。
インディーズアーティスト向け配信サービス「Eggs Pass」の開発を担当しています。

今年も残り少なくなってきましたね。
今年行った音楽イベントの振り返りをしてみたら、個人的ベストは[the Labyrinth](https://www.mindgames.jp/laby24-2)でした。
群馬の山奥でキャンプしてる隣で、ひたすらテクノが流れてるフェスで、
例年は5°Cくらいの寒さの中雨が降り続くことも多いのですが、今年は9年ぶりに雨が降らず暖かい中で最高空間を過ごしました。

この記事では、Elasticsearchにおける同義語（Synonym）の見落としがちな注意点についてご紹介します。


## 前提
この記事では、Elasticsearchの同義語（Synonym）についての概要と使用上の注意点にフォーカスします。
そもそもElasticsearchとは？について知りたい方は、こちらの公式ドキュメントをご参照ください。
https://www.elastic.co/guide/en/elasticsearch/reference/current/elasticsearch-intro-what-is-es.html

PythonでElasticsearch 7系を使用した実装例を紹介しています。

使用バージョン
- Python: 3.9.13
- Elasticsearch: 7.8.0

## 背景
Elasticsearchを使用している案件にて同義語検索を導入することになったのですが、実装を進めるにつれて
「インデックス時と検索時のどちらで同義語フィルターを使用するべきか？」
という疑問が浮上して来たので調査しました。


## 1. Synonymフィルターとanalyzerの基本

### 同義語（Synonym）
Elasticsearchの同義語（Synonym）機能は、検索の精度と関連性を向上させるために使用され、検索クエリやインデックスされたドキュメントにおける語句のバリエーションを処理するのに役立ちます。
たとえば、「パソコン」、「PC」、「パーソナルコンピューター」という単語が同義である場合、これらを一致させることで、どの言葉を入力しても同じ結果が得られるように設定できます。


```
（例）検索文字列：「パソコン」→ 「PC」の文字列を含むドキュメントでも検索にヒット
```

### analyzer
同義語（Synonym）は「analyzer」に設定します。
analyzerとは、テキストデータを処理してインデックスに適した形式に変換するためのコンポーネントです。
テキストを検索可能な単位（トークン）に分割し、検索の精度と効率を向上させる役割を果たします。

```
(例)「素早い茶色のキツネたちが怠け者の犬を飛び越えた」
→ [素早い、茶色、の、キツネ、たち、が、怠け者、の、犬、を、飛び越え、た]
```
日本語の場合、よく使用されるのがanalysis-kuromojiプラグインです。
https://www.elastic.co/guide/en/elasticsearch/plugins/7.8/analysis-kuromoji.html


analyzerを使用するタイミングとしては、
- インデックス時（Index time）
- 検索時（Search time）

の二つがあります。

インデックス時（Index time）と検索時（Search time）の説明はこちら。
https://www.elastic.co/guide/en/elasticsearch/reference/7.3/analysis.html


### analyzerへのSynonymの設定方法
analyzerにSynonymを設定するにはいくつか方法がありますが、
txtファイルなどに同義語を列挙しておいて、synonymフィルターの設定時にファイルのパスを指定する方法がよく紹介されたりします。

##### synonym.txt
```txt
パソコン, PC, パーソナルコンピューター
```

##### python
```python
analysis_settings = {
    ・・・
    "synonym_filter": {
        "type": "synonym",
        "synonyms_path": "/usr/share/elasticsearch/data/synonym.txt" # synonym.txtのパスを指定
    }
    ・・・
}
```

以下に、インデックス作成時と検索時における同義語フィルターの実装例を示します。

## 2. Synonymフィルターの設定実装例
### インデックス作成時の場合
```python
from elasticsearch import Elasticsearch

## Elasticsearchへの接続
es = Elasticsearch("localhost:9200", request_timeout=30, timeout=30)

## インデックスの設定
# synonym_filter を含んだ synonym_analyzer を定義
analysis_settings = {
    "analysis": {
        "tokenizer": {"kuromoji": {"type": "kuromoji_tokenizer"}},
        "filter": {
            "synonym_filter": {
                "type": "synonym",
                "synonyms_path": "/usr/share/elasticsearch/data/synonym.txt"
            }
        },
        "analyzer": {
            "synonym_analyzer": {
                "type": "custom",
                "tokenizer": "kuromoji",
                "filter": ["synonym_filter"],
            }
        },
    }
}

settings = {
    "settings": analysis_settings,
    "mappings": {
        "properties": {
            # synonym_analyzer をセット
            "content": {
                "type": "text",
                "analyzer": "synonym_analyzer"
            }
        }
    }
}

## インデックスの作成
index_name = '20241122_search_index'
es.indices.create(index=index_name, body=settings)

## ドキュメントの登録
doc = {
    "content": "私はPCを買いに行かねばなりません。"
}
response = es.index(index=index_name, body=doc)
```

ドキュメントの登録が完了したので、同義語で検索を行うと、

```python
from elasticsearch import Elasticsearch

## Elasticsearchへの接続
es = Elasticsearch("localhost:9200", request_timeout=30, timeout=30)

## ドキュメントの検索
search_query = {
    "query": {
        "match": {
            "content": 'パソコン' # synonym.txt に登録した同義語
        }
    }
}
index_name = '20241122_search_index'

response = es.search(index=index_name, body=search_query)
print(response)
## ＝> {'took': 6, 'timed_out': False, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}, 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'max_score': 0.78563094, 'hits': [{'_index': '20241122_search_index', '_type': '_doc', '_id': 'rBAOX5MB0wc21NNpqqa5', '_score': 0.78563094, '_source': {'content': '私はPCを買いに行かねばなりません。'}}]}}
#　↑類義語で検索ヒット成功！！
```

### 検索時の場合
```python
from elasticsearch import Elasticsearch

## Elasticsearchへの接続
es = Elasticsearch("localhost:9200", request_timeout=30, timeout=30)

## インデックスの設定
# ここでは、synonym_filter を設定しない
analysis_settings = {
    "analysis": {
        "tokenizer": {"kuromoji": {"type": "kuromoji_tokenizer"}},
        "filter": {},
    }
}

index_settings = {
    "settings": analysis_settings,
    "mappings": {
        "properties": {
            "content": {
                "type": "text"
            }
        }
    }
}

## インデックスの作成
index_name = '20241123_search_index'  # 作成するインデックスの名前
es.indices.create(index=index_name, body=index_settings)  # インデックスの作成

## ドキュメントの登録
doc = {
    "content": "私はPCを買いに行かねばなりません。"
}
response = es.index(index=index_name, body=doc) 

```

検索を行うタイミングで、synonymフィルターの設定を行う。

```python
from elasticsearch import Elasticsearch, client

## Elasticsearchへの接続
es = Elasticsearch("localhost:9200", request_timeout=30, timeout=30)

## 検索時用のanalyzerを定義
search_analyzer = {
    "settings": {
        "analysis": {
            "tokenizer": {
                "standard": {
                    "type": "standard"
                }
            },
            "filter": {
                "synonym_filter": {
                    "type": "synonym",
                    "synonyms_path": "/usr/share/elasticsearch/data/synonym.txt" # synonym.txtのパスを指定
                }
            },
            "analyzer": {
                "search_analyzer": {
                    "type": "custom",
                    "tokenizer": "standard",
                    "filter": ["lowercase", "synonym_filter"]
                }
            }
        }
    }
}

index_name = '20241123_search_index'  # 作成したインデックスの名前
try:
    # indexの更新(put_settings)を行うためには、一度indexをcloseする必要がある。
    close_res = client.IndicesClient.close(es, index=index_name)
    res = client.IndicesClient.put_settings(es, index=index_name, body=search_analyzer)

except Exception as e:
    print(f"Failed to update settings due to:'{e}'.")
    pass

# 更新が完了したら、indexをopen
open_res = client.IndicesClient.open(es, index=index_name)

## ドキュメントの検索
search_query = {
    "query": {
        "match": {
            "content": {
                "query": "パソコン",
                "analyzer": "search_analyzer"  # 検索時用のanalyzerを設定
            }
        }
    }
}

response = es.search(index=index_name, body=search_query)
print(response)
## ＝> {'took': 1, 'timed_out': False, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}, 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'max_score': 0.2876821, 'hits': [{'_index': '20241123_search_index', '_type': '_doc', '_id': 'rxCzYZMB0wc21NNp2aZf', '_score': 0.2876821, '_source': {'content': '私はPCを買いに行かねばなりません。'}}]}}
#　↑類義語で検索ヒット成功！！
```



## 3. Synonymの注意すべきこと
自分が担当していた案件の実装では、既にインデックス時と検索時に使用するanalyzerは別々に用意されていたため、どちらにもsynonymの設定を追記すればいいかー！くらいに考えてたのですが、

ここでふと思ったのが、そもそも「synonymフィルターは、インデックス時に使用するべき？それとも検索時？両方で使うべき？」という疑問。。。


### Synonymフィルターは検索時に行うべき！！

この疑問に対する回答は公式がキチンと出してくれていました！

> インデックス時の同義語フィルターの使用には、複数のデメリットがあります。
> - すべての同義語をインデックスする必要があるため、インデックスサイズが大きくなる可能性がある。
> - 用語の統計に依存する検索スコアリングにおいて、同義語もカウントされることで悪影響を受ける可能性があり、頻度の低い単語で統計に歪みがでる。
> - 再インデックスしない限り、既存のドキュメントの同義語ルールを変更できない。

引用：https://www.elastic.co/jp/blog/boosting-the-power-of-elasticsearch-with-synonyms

実は、インデックス時の同義語フィルターの使用には、大きなデメリットがあるため避けるべきとのこと。。
特に2個目と3️個目が大きいデメリットとして紹介されていて、

> 「用語の統計に依存する検索スコアリングにおいて、同義語もカウントされることで悪影響を受ける可能性があり、頻度の低い単語で統計に歪みがでる。」

この検索スコアリングとは、検索結果の関連性を評価するための仕組みで、検索クエリに対する各ドキュメントの適合度を示すスコアを計算し、このスコアリングによって検索結果の順位がどうなるかが決まります。
インデックス時にSynonymフィルターを使用すると、このスコアリングに悪影響を及ぼすため、本来意図していない順序や検索結果になってしまう可能性を高めてしまいます。


また、
> 再インデックスしない限り、既存のドキュメントの同義語ルールを変更できない。

については、インデックス時にSynonymを使用しているので、当然インデックスを改めて作成するタイミングでしか既存のドキュメントに対する同義語ルールは変更できません。


> 一方で、検索時のアナライザーで同義語フィルターを使用する場合、上述の多くの問題は生じません。
> - インデックスサイズに影響が出ない。
> - 用語の統計全体は同じに保たれる。
> - 同義語ルールを変更するにあたり、ドキュメントの再インデックスは必要ない。

引用：https://www.elastic.co/jp/blog/boosting-the-power-of-elasticsearch-with-synonyms

上記のように、インデックス時に起こるデメリットを回避できるため、Synonymフィルターの使用は検索時に行う方が好ましいということでした。

## まとめ
同義語フィルターを使用することで検索精度の向上やサービス固有の関連ワードを整理できますが、使用するタイミングには注意が必要です。上記のように、Synonymフィルターの使用は検索時に行うことを推奨します。

明日の [レコチョク Advent Calendar 2024](https://qiita.com/advent-calendar/2024/recochoku) は10日目「【Kotlin】レコチョクのAndroidエンジニアになるまで」です。お楽しみに！

この記事はレコチョクのエンジニアブログの記事を転載したものとなります。 
https://techblog.recochoku.jp/11322 

## 参考
- https://www.elastic.co/guide/en/elasticsearch/reference/current/elasticsearch-intro-what-is-es.html
- https://www.elastic.co/guide/en/elasticsearch/plugins/7.8/analysis-kuromoji.html
- https://www.elastic.co/guide/en/elasticsearch/reference/7.3/analysis.html
- https://www.elastic.co/jp/blog/boosting-the-power-of-elasticsearch-with-synonyms