【実践】ElasticSearch × RAG 入門 (4) – 埋め込みモデルを用いたHybrid Searchへ

前回の記事では、Elasticsearchを用いたBM25ベースの全文検索手法について解説しました。


今回はより高度な検索体験を提供するために、ベクトル(Embedding)検索について詳しく説明します。
これにより、ユーザーの意図や文脈を考慮した検索が可能となり、RAG(Retrieval-Augmented Generation)システムの精度向上が期待できます。


1. ベクトル(Embedding)検索とは?

ベクトル検索は、テキストや画像などのデータを高次元の数値ベクトルに変換し、その類似性を計算することで、意味的に関連する情報を検索する手法です。
従来のキーワードベースの検索とは異なり、文脈や意味を考慮した検索が可能となります。

たとえば、
「坊っちゃんが学校へ行った」という文と「生徒が登校した」という文は、
キーワードが異なっていても、ベクトル空間上では近い位置に配置されるため、類似した意味を持つ文として検索されます。

これが、意味検索の強みです。

Elasticsearchでは、dense_vectorフィールドを使用してベクトルを格納し、k近傍法(kNN)などのアルゴリズムを用いて類似度検索を実現します。


2. BM25との比較

BM25は、キーワードの出現頻度や文書の長さを考慮して関連度を計算するアルゴリズムであり、正確なキーワードマッチングに優れています。
一方、ベクトル検索は、文脈や意味の類似性を考慮するため、異なる表現でも関連性の高い情報を取得できます。

以下に、BM25とベクトル検索の主な特徴を比較した表を示します。

特徴BM25ベクトル検索(Embedding)
検索手法キーワードベース意味・文脈ベース
類似度の計算単語の出現頻度と逆文書頻度コサイン類似度やユークリッド距離
同義語の対応同義語(Synonym)辞書が必要 ❌(何も考える必要がない) ✅
処理速度高速 ✅普通 ❌
適用分野正確なキーワード検索意味的な検索、質問応答、推薦システム

BM25は高速で正確なキーワード検索に適していますが、ベクトル検索はユーザーの意図や文脈を考慮した柔軟な検索が可能です。

実際、業務で使う際はほとんどのケースがBM25で十分なことが多いです。
ElasticSearchでは BM25がデフォルトで使われます。


どちらにもメリット・デメリットがありますが、

0.5 * BM25 + 0.5 * EmScore

のようにして重み付けをして組み合わせることもできます。

これを (BM25とEmbeddingの)Hybrid Search と呼びます。

Hybrid Searchは、両者の良いとこ取りをします。
一方で重みのパラメータ調整などを行う必要があり、うまく調整できたとしてもRAG全体ではそこまで大きなboostにはならないことが多いです。

それよりかは、データベースの構造を顧客のドメイン知識に従って正しく設計する部分のほうが検索タスクでは大きく良い影響を与えます。


3. Embeddingの計算方法(OpenAI API)

ベクトル検索を行うには、まず文やクエリを「ベクトル」に変換する必要があります。
以下は、OpenAIの text-embedding-3-small を使ってベクトルを取得する方法です

import openai
import os

openai.api_key = os.getenv("OPENAI_API_KEY")

def get_embedding(text: str) -> list[float]:
    response = openai.embeddings.create(
        input=text,
        model="text-embedding-3-small"
    )
    return response.data[0].embedding

取得したベクトルは、Elasticsearchに格納することで意味検索に利用できます。


4. 実践例:elasticsearch-dslでEmbedding検索

ここでは、Pythonの elasticsearch-dsl を使って、Embedding検索を実装します。

4.1 インデックスの作成

まず、ベクトル格納用の dense_vector フィールドを含むマッピングを新しく定義します。

from elasticsearch_dsl import Document, Text, DenseVector, connections

connections.create_connection(hosts=["localhost"])

class BotchanLineWithEmbedding(Document):
    line = Text(analyzer=ja_analyzer)
    line_embedding = DenseVector(dims=1536)
    line_number = Integer()

    class Index:
        name = INDEX_NAME

Indexの構築については 第二回の記事を参考にしてください

4.2 インデックス登録

Embeddingを取得し、各文書に格納します。

docs = []
with open(TEXT_DATA_PATH, "r") as f:
    for i, line in tqdm(enumerate(f)):
        line = line.strip()
        if len(line) > 0:
            emb = get_embedding(line)
            doc = BotchanLineWithEmbedding(meta={"id": i}, line=line, line_number=i, line_embedding=emb)
            docs.append(doc.to_dict(include_meta=True))

bulk(es, docs, index=INDEX_NAME)

4.3 検索クエリの実行

次に、ユーザーの検索クエリに対してベクトルを生成し、cosine類似度を使って検索します。

from elasticsearch_dsl import Search
from elasticsearch_dsl import connections

client = connections.create_connection(hosts=["http://localhost:9200"], timeout=20)
query_text = "激おこぷんぷん丸"
query_vector = get_embedding(query_text)
s = Search(using=client, index="botchan").query(
    "script_score",
    query={"match_all": {}},
    script={
        "source": "cosineSimilarity(params.query_vector, 'line_embedding')",
        "params": {"query_vector": query_vector}
    }
)
response = s.execute()

for hit in response:
    print(f"{hit.meta.score:.2f}: {hit.line}")

実行結果は以下のようになります:

0.31: おやじはちっともおれを可愛かわいがってくれなかった。 ... 乱暴で乱暴で ...

激おこですね…😡


おわりに

ここまではElasticSearchの基礎的な使い方、環境構築の方法を紹介してきました。
また、キーワードの精度と意味検索の柔軟性を両立させることで、RAGのための性能向上が期待できることも分かりました。

次回はいよいよ、langchainを使ったRAGを実践してみましょう。
お疲れ様でした!

コメント