ハイブリッド検索

BLOG

1. 前書き

こんにちは。
サイオステクノロジーの田川です。
今回は、キーワード検索とベクトル検索を組み合わせたハイブリッド検索を行ってみます。

対象者

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

できるようになること

  • Elasticsearch で日本語のハイブリッド検索(キーワード検索とベクトル検索のRRF検索)を行えるようになる。
  • Elasticsearch で検索用テンプレートを使った検索を行えるようになる。

前提条件

  • Elastic Cloud (version: 8.15.0)
  • Elastic Cloud 上に日本語でのベクトルデータを投入済みである。
    前々回の記事に日本語でのベクトルデータの投入作業を記載しています。

(2024年11月 5日時点の情報を元に記載しています。)

2. ハイブリッド検索

以前の記事で、日本語でのキーワード検索ベクトル検索を行いましたが、

  • キーワード検索では上位にランキングされるのにベクトル検索では下位にランキングされるクエリ

あるいは逆に

  • ベクトル検索では上位にランキングされるのにキーワード検索では下位にランキングされるクエリ

があったりします。

これらを平準化するための一つの方法がハイブリッド検索です。

キーワード検索とベクトル検索の両方を行い、検索漏れを少なくしようというものです。

Elasticsearch では、ハイブリッド検索の方法として2種類の方法を利用可能です。

  • スコアベース
    <キーワード検索のスコア> と <ベクトル検索のスコア> から <総合的なスコア> を算出する。
  • 順位ベース
    <キーワード検索の順位> と <ベクトル検索の順位> から <総合的な順位> を算出する。(RRF)

今回は、後者の方法(RRFを使った順位ベースのハイブリッド検索)を使います。
(*脚注1)1

3. RRF

Reciprocal Rank Fusion (RRF) は、複数の検索結果の順位から、最終的な順位を決定するアルゴリズムです。

score = 1 / (検索方法1での順位 + rank_constant) + 
        ... + 
        1 / (検索方法nでの順位 + rank_constant)

により score を計算して、score が高いものの順に検索結果を並び替えます。
(*脚注2)2

RRFによるハイブリッド検索を行うリクエストの例:
(キーワード検索:1回、ベクトル検索:1回)

GET /momotaro_v3/_search
{
  "_source": false,
  "fields": [ "chunk_no", "content" ],
  "size": 10,
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "match": {
                "content": "キーワード検索用クエリ"
              }
            }
          }
        },
        {
          "knn": {
            "field": "text_embedding.predicted_value",
            "k": "10",
            "num_candidates": "100",
            "query_vector_builder": {
              "text_embedding": {
                "model_id": ".multilingual-e5-small_linux-x86_64",
                "model_text": "ベクトル検索用クエリ"
              }
            }
          }
        }
     ],
     "rank_window_size": "10",
     "rank_constant": "60"
    }
  }
}

RRF検索を行いたい場合、”retriever” の “rrf” を利用します。

“rrf” 内の “retrievers” 配列に、リランク元の順位を与える検索内容を記述します。

上記の例では、キーワード検索:1回、ベクトル検索:1回としていますが、

キーワード検索:2回、ベクトル検索:3回 や、キーワード検索:2回、ベクトル検索:0回
のようなことも可能です。
(ただし検索回数を多くすると、その分、遅くなったりリソースを消費したりします。)

上記のリクエスト内の “k”, “num_candidates”, “rank_window_size”, “rank_constant” の値は、
リランク処理に影響を与えるパラメータです。

おおまかに説明すると下表のようになります。

項目名説明上記での設定値
kベクトル検索時に上位何件の検索結果を返すか?10
num_candidates各シャードで何件の候補を出すか?100
rank_window_size各検索時に、何件返すか?10
rank_constant前述の RRF のスコア計算時の定数(rank_constant)60

(*脚注3)3
実運用でRRFによるリランク処理を行いたい場合は、これらのパラメータの値を調整する必要があります。

今回は、サンプルですので、細かい調整は行わずに、上記の値を使用します。

4. 実際にハイブリッド検索してみる。

実際に、ハイブリッド検索を行ってみます。

検索例:

  • キーワード検索用クエリ: “桃太郎の家来が桃太郎からもらったもの”
  • ベクトル検索用クエリ: “桃太郎の家来が桃太郎からもらったもの”

※キーワード検索用のクエリ と ベクトル検索用のクエリは異なっていてもかまいませんが、
サンプルとして同じ内容を指定しています。

(参考)キーワード検索の結果:

第2位
"chunk_no": [ 82 ]
"content": [ " 犬はきびだんごを一つもらって、桃太郎のあとから、ついて行きました。" ]
...
第3位
"chunk_no": [ 102 ]
"content": [ " きじもきびだんごを一つもらって、桃太郎のあとからついて行きました。" ]

(参考)ベクトル検索の結果:

第4位
"chunk_no": [ 82 ]
"content": [ " 犬はきびだんごを一つもらって、桃太郎のあとから、ついて行きました。" ]
...
第6位
"chunk_no": [ 102 ]
"content": [ " きじもきびだんごを一つもらって、桃太郎のあとからついて行きました。" ]

ハイブリッド検索の結果:

第1位
"chunk_no": [ 82 ]
"content": [ " 犬はきびだんごを一つもらって、桃太郎のあとから、ついて行きました。" ]
...
第3位
"chunk_no": [ 102 ]
"content": [ " きじもきびだんごを一つもらって、桃太郎のあとからついて行きました。" ]

1位と3位にヒットしています。

他のクエリでも試したいところですが、毎回、前述のような長いリクエストを発行するのが
面倒だと思われた方もいらっしゃるかもしれません。

そういった問題を解消するために、Elasticsearchでは、検索用テンプレートの機能が用意されています。

検索用テンプレートを使用するメリットとして、下記などが挙げられます。

  • Python などの Client から検索する場合に、Python側のコードが簡潔になる。
  • 検索用クエリを修正する場合、Python側のコードの修正は不要。
  • Elasticsearch側のクエリを書く人と、Python側の呼び出し用コードを書く人の作業分担がしやすい。

(*脚注4)4

下記のリクエストにより、検索用テンプレートを作成します。

PUT _scripts/rrf_search_template
{
  "script": {
    "lang": "mustache",
    "source": """{
      "_source": false,
      "fields": [ "chunk_no", "content" ],
      "size": "{{size}}{{^size}}10{{/size}}",
      "retriever": {
        "rrf": {
          "retrievers": [
            {
              "standard": {
                "query": {
                  "match": {
                    "content": "{{query_string}}"
                  }
                }
              }
            },
            {
              "knn": {
                "field": "text_embedding.predicted_value",
                "k": "{{size}}{{^size}}10{{/size}}",
                "num_candidates": "{{num_candidates}}{{^num_candidates}}100{{/num_candidates}}",
                "query_vector_builder": {
                  "text_embedding": {
                    "model_id": ".multilingual-e5-small_linux-x86_64",
                    "model_text": "{{query_for_vector}}"
                  }
                }
              }
            }
         ],
         "rank_window_size": "{{size}}{{^size}}10{{/size}}",
         "rank_constant": "{{rank_constant}}{{^rank_constant}}60{{/rank_constant}}"
        }
      }
    }
    """
  }
}

上記の検索テンプレート内では、下記のパラメータを指定可能としています。

パラメータ名説明ディフォルト値
sizeハイブリッド検索結果の返却件数10
query_stringキーワード検索クエリなし
num_candidatesベクトル検索時のシャードごとの候補の件数100
query_for_vectorベクトル検索クエリなし
rank_constantRRF検索時の rank_constant60

(k=size, rank_window_size=size としています。)

いったん上記で検索用テンプレートを作ってしまえば、
これ以降は検索用テンプレートを使って検索することができるようになります。

検索用テンプレートを使って検索リクエストを書いてみます。

  • キーワード検索用クエリ: “桃太郎の家来になった動物”
  • ベクトル検索用クエリ: “桃太郎の家来になった動物”
GET /momotaro_v3/_search/template
{
  "id": "rrf_search_template",
  "params": {
    "query_string": "桃太郎の家来になった動物",
    "query_for_vector": "桃太郎の家来になった動物"
  }
}

(“id” に指定している “rrf_search_template” は、先ほど作成した検索用テンプレートの id です。)

随分と簡単に書けるようになりました。

これを実行した結果を見てみます。

(参考)キーワード検索の結果:

第2位
"chunk_no": [ 103 ]
"content": [ "  犬と、猿と、きじと、これで三にんまで、いい家来ができたので..." ]

(参考)ベクトル検索の結果:

第1位
"chunk_no": [ 103 ]
"content": [ "  犬と、猿と、きじと、これで三にんまで、いい家来ができたので..." ]

ハイブリッド検索の結果:

第1位
"chunk_no": [ 103 ]
"content": [ "  犬と、猿と、きじと、これで三にんまで、いい家来ができたので..." ]

キーワード検索では2位でしたが、ハイブリッド検索では1位になりました。

他の例も見てみます。

  • キーワード検索用クエリ: “おじいさんが山へ行ったのはなぜ”
  • ベクトル検索用クエリ: “おじいさんが山へ行ったのはなぜ”
GET /momotaro_v3/_search/template
{
  "id": "rrf_search_template",
  "params": {
    "query_string": "おじいさんが山へ行ったのはなぜ",
    "query_for_vector": "おじいさんが山へ行ったのはなぜ"
  }
}

(参考)キーワード検索の結果:

第1位
"chunk_no": [ 1 ]
"content": [ "...おじいさんは山へしば刈りに...行きました。" ]

(参考)ベクトル検索の結果:

第2位
"chunk_no": [ 1 ]
"content": [ "...おじいさんは山へしば刈りに...行きました。" ]

ハイブリッド検索の結果

第1位
"chunk_no": [ 1 ]
"content": [ "...おじいさんは山へしば刈りに...行きました。" ]

ベクトル検索では2位でしたが、ハイブリッド検索では1位になりました。

良好な結果が得られています。

5. まとめ

このように Elasticsearch では、独自に難しいロジックを組むことなく、
ハイブリッド検索を行うことができます。
今回はサンプルでデータ件数が少ないこともありますが、検索も高速です。
(*脚注5)5

また、検索用テンプレートを活用することで呼び出し側のコードを簡潔にすることができました。

次回は、Python を使った Elasticsearch の操作について紹介したいと思います。


  1. キーワード検索のスコア と ベクトル検索のスコア から総合的なスコアを算出したい場合は、
    下記を参照してください。
    https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html#_combine_approximate_knn_with_other_features
    単純に
     キーワード検索のスコア + ベクトル検索のスコア
    とするだけでなく、
     キーワード検索のスコア + ベクトル検索のスコア*10
    のようなスコア計算を行うことも可能です。
    ↩︎
  2. RRF の詳細については、下記を参照してください。
    https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html
    https://www.elastic.co/guide/en/elasticsearch/reference/current/retriever.html#rrf-retriever-example-hybrid
    ↩︎
  3. RRFによるハイブリッド検索時のパラメタの詳細は、下記を参照してください。 https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html#rrf-api
    https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search-api.html#knn-search-api-request-body
    ↩︎
  4. 検索テンプレートの詳細については、下記を参照してください。
    https://www.elastic.co/guide/en/elasticsearch/reference/current/search-template.html
    ( _render を使って、作成した検索テンプレートをテストすることもできます。)
    ↩︎
  5. ハイブリッド検索にすれば、それだけで完璧というわけではありません。
    格納するデータのチャンクサイズの調整を行ったり、検索対象のデータのメタ情報(タイトル、作成者、最終更新日時など)を考慮した検索を行った方がいい場合もあります。
    その他、Elasticsearch v8.14 からは、
    キーワード検索 + (Cohereなどを使った)セマンティックリランク
    といった手法も行えるようになりました。こちらは、今後、紹介する予定です。
    ↩︎