ElasticsearchにおけるSparseベクトル検索 / ELSER, SPLADE, 日本語SPLADE

2025/02/25 追記

公式ブログに同じテーマでより詳しく書いた記事を投稿しました。英語ですがこちらも参照してください。

https://www.elastic.co/search-labs/blog/sparse-vector-embedding

# はじめに

Elasticsearchでは元々Transformerという仕組みを使った機械学習モデルをElasticsearch上で動かし、テキストをDenseベクトルに変換（エンべディング）する仕組みを提供していました。しかしSparseベクトルへのエンべディングについてはElasticが提供するELSERというモデルのみに対応している状況でしたが、後述もする日本語SPLADEなどのモデルが発表されてきていることもわかったので、ELSER以外のSparseモデルも対応できるように修正のPRを送ってマージされました。

https://github.com/elastic/elasticsearch/pull/116935

https://github.com/elastic/eland/pull/740

これによってバージョン8.17からSparseベクトルを生成するモデルをElandからアップロードし、Denseベクトルと同様にベクトル検索に利用することができるようになりました。この記事ではまずSparseベクトルの概要を説明してから、実際にElasticsearchを使ってSparseベクトルによるセマンティック（意味）検索を実装する方法について紹介します。

:::note info
ライセンス
この記事の内容を試すにはElasticsearchのPlutinam以降、あるいはTrialライセンスが必要です。
:::

# ベクトル検索の種類

現在、キーワード検索に代わるインテリジェントな検索手法としてベクトル検索が広く利用されるようになってきました。単語単位の転置インデックスを走査することですばやく目的のドキュメントを見つける従来のTF/IDFベースの手法（BM25など）と異なり、ベクトル検索とは入力テキストを数値の列（ベクトル）に変換しておき、クエリーのベクトルとの幾何学的な近さ（角度や距離など）で、より意味的に近いドキュメントを見つける手法です。

そんなベクトル検索ですが、実は実用化されているベクトル検索の実装には以下の二つの種類があります。（これ以外にもあるかもしれませんが、ここでは議論しません。）

 - 密ベクトル（Dense vector）を使った検索
 - 疎ベクトル（Sparse vector）を使った検索

通常、ベクトル検索という場合には前者の密ベクトル（Denseベクトル）を使った検索を意味していることがほとんどだと思います。Elasticsearchで密ベクトルを使った日本語検索を実現する方法については以下で紹介しています。

https://qiita.com/daixque/items/931b8be343075b835097

しかし疎ベクトル（Sparseベクトル）を使った検索については、特に日本語での情報はあまり聞かれません。疎ベクトル（Sparseベクトル）による検索とはどんなものなのでしょうか。

（この記事では以降、Elasticsearch内での機能の呼び方に合わせてDenseベクトルおよびSparseベクトルと英名で表記します。）

# Sparseベクトルとは何か

## Semantic検索におけるSparseベクトル

Semantic検索におけるSparseベクトルでは、前述の通り、ベクトルにはDenseベクトルとSparseベクトルという二つの表現方法があるわけですが、これらはどのように違うのでしょうか。学術的な詳細は私の手に余るので、プラグマティックに解説したいと思います。

あるテキストを特定のルールで**一定の長さの**数値の配列に変換するとき、その出力された数値の配列のことDense vectorと言います。またこの変換処理のことを通常embed(埋め込み)と呼びます。以下、イメージコードです。

```python
embed("Dense vectorとは何か")
# => [2,5,2,7,8,2,3...]
```

配列のそれぞれの要素は、何らかの特徴量を表していると考えることができます（通常N番目の要素が何の特徴量を表しているかは、ユーザーから見ると不明です）。入力されたテキストの意味内容を、決められた長さの特徴量の列に圧縮しているわけですね。この例では入力テキストは非常に短いですが、長いテキストを入力しても同じ次元のベクトルが出力されます。

それに対してSparseモデルは、無数にありうる意味成分のうち、そのテキストが持っている成分のみを抽出します。とにかく例を見てみましょう。

```python
expand("Sparse vectorとは何か")
# => {
        "用語": 0.57665896,
        "て": 0.57438034,
        "語": 0.4170748,
        "技術": 0.103720225,
        ...
      }
```

見てわかる通り、キーワードに対してそのスコア、という形で出力されます。また同時に分かるようにここでリストアップされるキーワードは、元のテキストに含まれているとは限りません。利用したモデルが、このテキストに対して評価し、その入力から推測されるキーワード展開してスコアをつけています。このキーワードの方はモデルが持っている語彙の分だけ成分としてはありうるはずですが、そのうちスコアをつけたものだけが出力され、それ以外は全て0のスコアという意味になります。したがって出力された要素以外の全ての要素の値はゼロであるという、「疎」なベクトルが出力されたわけですね。

## BM25

ちなみに、Elasticsearchでこれまでにキーワード検索として利用してきたBM25（[Okapi BM25](https://ja.wikipedia.org/wiki/Okapi_BM25)）やTF-IDFも、実はクラシカルな意味でのSparseベクトルに分類される検索です。これらは対象の文書を、コーパス全体の語彙を要素とするSparseベクトルとして取り扱っています。各文書のベクトルは[Term vector API](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-termvectors.html)で取得することができます。

ただしこの記事ではMLモデルによるSparseベクトルを対象としているので、BM25についてはこれ以上取り上げません。

# Elasticsearchで使えるSparseモデル: ELSER

元々Sparse vectorによる検索はNaverが開発したモデルであるSPLADEによって広まったようです。

https://europe.naverlabs.com/research/machine-learning-for-robotics/splade-models/

SPLADEは特に英語では高い関連性のスコアを発揮することがわかっています。そこでElasticもこのモデルの論文をベースに独自にELSERというモデルを開発しました。

https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-elser.html

このELSERは極めて高性能なモデルで、Elasticではセマンティック検索を実装する際のファーストチョイスとして推奨しています。

現在ではElasticsearchにはこのELSERのv2がバンドルされているため、Plutinam以降（あるいはTrial）のライセンスを使っている場合はすぐに使い始めることができます。ただし、このELSERは英語でトレーニングされているため、英語に対してしか適切な性能を発揮することができません。

# 日本語によるSparseベクトル検索

これまでElasticsearchでSparseベクトル検索を実装する際はELSERを利用することがほとんどでした。そして上述の通りELSERは英語以外に対応していません。したがってこれまで日本語などの非英語圏のベクトル検索といえばDenseベクトルを用いる方法が取られてきました。

しかしElasticsearch 8.17から、SparseモデルもDenseモデルと同様に外部からアップロードできるようになりました。これには以下の条件を満たす日本語のモデルも含まれます。この制限はテキストのトークナイズに関連するもので、Dense vectorのモデルと同様です。

 - BERTモデルであること
 - tokenizer_config.jsonファイルのword_tokenizer_typeの値がmecabであること

[以前のポスト](https://qiita.com/daixque/items/931b8be343075b835097#nlp%E3%83%A2%E3%83%87%E3%83%AB%E3%81%AE%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88)も参考にしてください。

この条件に該当してElasticsearchで利用できることを確認しているSparseベクトルモデルには、例えば以下がありました。

**hotchpotch/japanese-splade-v2**

https://huggingface.co/hotchpotch/japanese-splade-v2

**aken12/splade-japanese-v3**

https://huggingface.co/aken12/splade-japanese-v3/tree/main

これらのモデルを利用することで、Elasticsearch上でSparseベクトルを使ったセマンティック検索を実装することができるようになります。

:::note info
SparseベクトルをElasticsearchの外部で生成し、Elasticsearchにsparse_vector/rank_featureとしてインデックスする方法であれば、以前からSparseベクトルによる検索は[Rank feature query](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-rank-feature-query.html)を使って実装可能でした。8.17から可能になったのは、MLモデル自体をElasticsearchにアップロードし、Elasticsearch上でエンべディングを実行することです。
:::

# 日本語Sparseベクトル検索の実装

それでは実際にElasticsearchで日本語Sparseベクトルを使った検索を実装してみましょう。基本的な利用方法はELSERと全く同じです。ELSERでのチュートリアルは以下を参照してください。モデル名の部分を読み替えれば、ほぼそのまま他のSparseモデルでも同じ手順になります。

https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-search-elser.html

ただしELSERはElasticsearchにバンドルされていますが、それ以外のSparseモデルはElandを使ってマニュアルでElasticsearchにアップロードする必要があるところが異なります。

## Sparseモデルのアップロード

Denseモデルと同様、選択したモデルをElasticsearch上で動かしてエンべディングに利用するには、そのモデルをElandというツールを使ってHugging FaceからElasticsearchにアップロードする必要があります。また、SparseモデルについてはElandの方もバージョンが8.17以降である必要があります。

まずはElandをインストールします。

```
$ pip install fugashi
$ pip install unidic_lite
$ pip install 'eland[pytorch]'
```

テキストのSparseベクトルへの展開は`text_expansion`というタスクタイプになります（このタスクタイプがサポートされるのがEland 8.17からです）。

elandをインストールすると利用できるようになる`eland_import_hub_model`コマンドでHugging FaceにあるモデルをElasticsearchにアップロードします。

```
$ eland_import_hub_model \
--url "https://your.elasticserach" \
--es-api-key "your_api_key" \
--hub-model-id aken12/splade-japanese-v3 \
--task-type text_expansion \
--start
```

ところで、現在テキストをSparseベクトルに変換するタスクのことを`text_expansion`と呼んでいますが、今後`sparse_embedding`に変更になるかもしれません。以下のIssueで管理されています。

https://github.com/elastic/elasticsearch/issues/119576

## マッピングの作成

Sparseベクトルは`sparse_vector`型か、あるいは`rank_features`型としてインデックスされる必要があります。ここではわかりやすい`sparse_vector`型の例で説明します。

```
PUT sparse-test
{
  "mappings": {
    "properties": {
      "content_embedding": { 
        "type": "sparse_vector" 
      },
      "content": { 
        "type": "text" 
      }
    }
  }
}
```

## Ingest Pipelineを使ったSparseベクトルの展開

Elasticsearchへのデータ投入時には、入力されたテキストをSparseベクトルに変換する必要があります。この処理はIngest PipelineでInference Processorを使って行います。

この例では、`content`フィールドに入力されたテキストデータを、先ほどアップロードしたモデルである`aken12__splade-japanese-v3`を利用してSparseベクトルにエンベッドしてそれを`content_embedding`フィールドに保存するパイプラインを定義しています。

```
PUT _ingest/pipeline/sparse-test-pipeline
{
  "processors": [
    {
      "inference": {
        "model_id": "aken12__splade-japanese-v3",
        "input_output": [ 
          {
            "input_field": "content",
            "output_field": "content_embedding"
          }
        ]
      }
    }
  ]
}
```

データ投入時はこのパイプラインを通せば、`content_embedding`フィールドにSparseベクトル表現の値が展開されます。

```
POST sparse-test/_doc/1?pipeline=sparse-test-pipeline
{
  "content": "Elasticsearchへのデータ投入時には、入力されたテキストをSparseベクトルに変換する必要があります。この処理はIngest PipelineでInference Processorを使って行います。"
}
```

単にベクトル化を試したい場合は[Infer trained model API](https://www.elastic.co/guide/en/elasticsearch/reference/current/infer-trained-model.html)が利用できます。

```
POST _ml/trained_models/aken12__splade-japanese-v3/_infer
{
	"docs": [{"text_field": "Elasticsearchへのデータ投入時には、入力されたテキストをSparseベクトルに変換する必要があります。この処理はIngest PipelineでInference Processorを使って行います。"}]
}
```

レスポンスは以下の通り。

```
{
  "inference_results": [
    {
      "predicted_value": {
        "は": 7.6684365,
        "に": 7.377538,
        "の": 7.1618214,
        "が": 6.860925,
        ...
      }
    }
  ]
}
```

## 検索

検索時には[Sparse vector query](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-sparse-vector-query.html)を利用します。クエリーに対してもSparseベクトルにエンベッドする必要があるため、Ingest Pipelineと同様に利用するモデルを指定する必要があります。

```
GET sparse-test/_search
{
   "query":{
      "sparse_vector":{
         "field": "content_embedding",
         "inference_id": "aken12__splade-japanese-v3",
         "query": "セマンティック検索のためのデータ投入"
      }
   }
}
```

# 終わりに

ElasticsearchでELSER以外のSparseベクトル検索を実装する方法について紹介しました。検索の精度はモデルのエンべディング結果に完全に依存しますが、これまでの密ベクトル以外の選択肢も出てきたのでぜひ試してみてください。

また今回はエンべディング結果をsparse_vectorとして利用していますが、これをfeature rankとして捉えれば、アイディア次第では入力されたテキストの特徴量抽出の一つの機能として利用することもできそうです。新しい利用方法も併せて考えてもらえると嬉しいです。

# Trouble Shooting

Elasticsearchのバージョンが8.17未満の場合、以下のエラーが発生します。Elasticsearchのバージョンを8.17以降にあげてください。

```
elasticsearch.BadRequestError: BadRequestError(400, 'x_content_parse_exception', 'text expansion models must be configured with BERT tokenizer, [bert_ja] given')
```

またElandが8.17未満の場合、以下のエラーが発生します。同様にElandのバージョンを8.17以降にあげてください。（エラーメッセージ的には`text_expansion`がサポートされるように出力されていますがバグです。）

```
TypeError: Unknown task type text_expansion, must be one of: fill_mask, ner, pass_through, question_answering, text_classification, text_embedding, text_expansion, text_similarity, zero_shot_classification
```