【実践】ElasticSearch × RAG 入門 (3) – RAGのための様々な検索をしてみよう

前回は、青空文庫「坊っちゃん」を素材として、Elasticsearchのインデックスを作成しました。

今回はこのインデックスを使って、RAG(Retrieval-Augmented Generation)に活用できるような、具体的な検索クエリのバリエーションを実践します。


今回使うデータ構造(Mapping)の概要

Elasticsearchでは、各インデックスが「どのような構造のデータを持つか」を Mapping として定義します。

今回の構造は以下のようになっています:

class BotchanLine(Document):
    line = Text(analyzer=ja_analyzer)
    line_number = Integer()
    class Index:
        name = INDEX_NAME
  • line
    小説の1行を保持するテキストフィールド。ja_analyzer により日本語の形態素解析が行われるため、「坊っちゃんが学校へ行った」のような文も柔軟に検索できます。
  • line_number
    元の文章中の行番号。検索結果の順序確認などに利用できます。

今回は非常に簡単なMapping(≒ スキーマ)を定義していますが、
このようなMappingでも、後述する柔軟な検索クエリが可能になります。

1. 単語検索(Matchクエリ)

もっとも基本的な全文検索の例です。line フィールドに「坊っちゃん」という語が含まれる文を検索してみましょう。

from elasticsearch_dsl import Search
from elasticsearch_dsl import connections

client = connections.create_connection(hosts=["http://localhost:9200"], timeout=20)
s = Search(using=client, index="botchan").query("match", line="坊っちゃん")
response = s.execute()

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

このクエリは、ja_analyzer によりトークナイズされた単語の中に「坊っちゃん」があるかを探します。
ヒットした結果は、関連度スコア(BM25)の高い順に並んで返されます。


2. 複数語検索(AND / OR条件)

複数のキーワードで検索したい場合は、match クエリにスペース区切りで語を渡します。

# OR検索(どちらかが含まれていればヒット)
s = Search(using=client, index="botchan").query("match", line="教師 正義")

# AND検索(両方の語を含む場合のみヒット)
s = Search(using=client, index="botchan").query("match", line={
    "query": "教師 正義",
    "operator": "and"
})

このように、クエリ中の単語の組み合わせによって柔軟に意味のある行を抽出できます。
RAGの文脈では、ユーザープロンプトから抽出したキーワードでの検索に役立ちます。


3. スコアリングの仕組み:BM25とは?

Elasticsearchでは、検索結果の関連度をスコアで表現します。これには BM25(Best Matching 25) というアルゴリズムが使われています。

スコアの計算には以下の要素が関わります:

要素内容
TF(Term Frequency)キーワードの出現頻度が高いほどスコアが上がる
IDF(Inverse Document Frequency)文書全体の中で希少なキーワードほどスコアが上がる
文書長の正規化長文にキーワードが散らばっていても不利にならないように補正

これにより、ユーザーの意図に合った「重要そうな文」がスコア上位に来る仕組みです。RAGではこのスコアを元に「上位n件」の文をLLMに渡します。


4. 複合的な条件で検索する(Booleanクエリ)

たとえば「教師」「正義」「努力」などの語を柔軟に組み合わせて検索したい場合は、bool クエリを使います。

from elasticsearch_dsl import Search
from elasticsearch import Elasticsearch

client = Elasticsearch()

s = Search(using=client, index="botchan").query({
    "bool": {
        "should": [
            {"match": {"line": "教師"}},
            {"match": {"line": "正義"}},
            {"match": {"line": "努力"}}
        ],
        "minimum_should_match": 1
    }
})
response = s.execute()

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

ここで使っている "bool" クエリは、複数の条件をAND/OR/NOTで論理的に組み合わせるためのものです。

  • should: OR条件(いずれかが当てはまればよい)
  • must: AND条件(すべて当てはまる必要がある)
  • must_not: NOT条件(当てはまるものを除外)

elasticsearch_dsl では Q(...) というショートカットもありますが、
今回は可読性を重視して query(...) 形式で統一しています。


5. よく使うElasticsearchのクエリ一覧(term, match, match_phrase など)

クエリ名説明主な用途具体例
termトークン化されていない完全一致の検索。大文字小文字も区別される。IDや状態コード、タグなどの正確な値の検索(例: "status" = "draft"query("term", line="坊っちゃん")
matchトークン化された語に対して検索。BM25スコアが付与される。一般的な全文検索(例: "教師" を含む文を抽出)query("match", line="教師")
match_phraseトークン化された語の連続した並びにマッチ。語順も考慮される。固定表現や語順を重視した検索(例: "坊っちゃん 先生"query("match_phrase", line="坊っちゃん 先生")
multi_match複数フィールドにまたがる match 検索。タイトルと本文の両方を検索したいとき(例: "正義" が title か line にある)query("multi_match", query="正義", fields=["title", "line"])
bool複数クエリを論理演算で組み合わせる(must/should/must_not など)。AND/OR条件で柔軟な検索(例: "教師" または "努力" を含む)query({"bool": {"should": [{"match": {"line": "教師"}}, {"match": {"line": "努力"}}], "minimum_should_match": 1}})
range数値や日付の範囲条件を指定。特定の範囲に絞って検索(例: 50〜100行目だけ表示query("range", line_number={"gte": 50, "lte": 100})
exists指定フィールドが存在するかどうかをチェック。空でないデータの抽出(例: "line_number" が存在する文だけ)query("exists", field="line_number")
wildcardワイルドカード(例: 坊っ*)での曖昧検索。部分一致検索をしたいとき(例: "坊っ" で始まる語を含む文)query("wildcard", line="坊っ*")

※ 説明のしやすさのために、title などの(今回のMappingでは)存在しないfieldも用いています🙇‍♂️

☕ 分かりづらい点:term と match の違いとは?

検索クエリを書くとき、term クエリと match クエリの違いで戸惑う方は多いと思います。

どちらも「ある単語に一致する文を探したい」という用途で使いそうに見えますが、内部的な動作がまったく異なります


match クエリは Analyzer を通す(自然文向け)

query("match", line="坊っちゃんが学校に行った")

このクエリでは、「坊っちゃんが学校に行った」という自然文が、検索時にも Analyzer を通してトークン化されます。

例えば、以下のように分割されると考えられます:

["坊っちゃん", "学校", "行っ", "た"]

これにより、インデックスされたドキュメントのトークン(事前にAnalyzerで処理された語)とマッチして、スコア付きでヒットします。


term クエリは Analyzer を通さない(完全一致のみ)

query("term", line="坊っちゃんが学校に行った")

こちらは入力クエリをそのまま使うため、Analyzerによるトークン化を一切行いません。そのため、インデックス側で登録された「完全な文字列」とまったく同じでなければヒットしません


よくある混乱:「インデックス時のAnalyzer」とのごちゃまぜ

Elasticsearchを初めて使う人が混乱するのは、次の2つの役割が頭の中で混ざってしまうからです。

フェーズAnalyzerが関与する?
インデックス時✅(フィールドのMappingで指定)Text(analyzer=ja_analyzer) によって "坊っちゃんが来た"["坊っちゃん", "来た"] に分割
検索時⛔(term) / ✅(matchterm はそのまま比較、match は同じようにトークン化してから比較
  • 文や会話、自然言語で検索するならmatchクエリを使うのが基本
  • term クエリは、IDやコード、短いラベルなどを正確に探すときにだけ使いましょう
  • 「検索時にAnalyzerが動く」かどうかが、Elasticsearch検索の本質的な理解ポイントです

具体例を紹介します。
まず、前提として以下のような生文章をDocument(≒ 行・レコード)としてIndexに登録したとします。

"坊っちゃんは学校に行った"

この生文章はIndexに登録する前に、ja_analyzer によって前処理されて
以下のようにトークン化されています。

["坊っちゃん", "は", "学校", "に", "行っ", "た"]


このとき

クエリ種別検索クエリクエリ側の処理ヒットする?理由
match"坊っちゃん"Analyzerで "坊っちゃん"トークン一致
match"学校""学校"同上
match"坊っちゃん 学校"["坊っちゃん", "学校"]両方存在(OR検索)
match"美術""美術"インデックスに存在しない
match"坊っちゃん 美術"["坊っちゃん", "美術"]坊っちゃんのみ一致(OR)
term"坊っちゃん"そのまま "坊っちゃん" (※ termは入力をAnalyzeしない)トークンと完全一致(登録済み)
term"学校""学校"同上
term"坊っちゃんは学校に行った"そのまま全文この全文はトークンとして存在しない
term"坊っちゃんは""坊っちゃんは"存在しないトークン
term"行っ""行っ"トークンと一致
term"坊っちゃん が""坊っちゃん が"複数語・助詞付きは一致しない

まとめると、

ポイント解説
match はクエリを トークン化して検索する柔軟な自然文検索に適している
term はクエリを そのまま検索するが、トークンと一致すればヒットする検索側でAnalyzerが動かないだけで、インデックス済みのトークンと合えばOK

となります。

☕ その他:よく使うElasticsearchのクエリ一覧

クエリ名説明主な用途具体例
termトークン化されていない完全一致の検索。大文字小文字も区別される。IDや状態コード、タグなどの正確な値の検索(例: "status" = "draft"query("term", line="坊っちゃん")
matchトークン化された語に対して検索。BM25スコアが付与される。一般的な全文検索(例: "教師" を含む文を抽出)query("match", line="教師")
match_phraseトークン化された語の連続した並びにマッチ。語順も考慮される。固定表現や語順を重視した検索(例: "坊っちゃん 先生"query("match_phrase", line="坊っちゃん 先生")
multi_match複数フィールドにまたがる match 検索。タイトルと本文の両方を検索したいとき(例: "正義" が title か line にある)query("multi_match", query="正義", fields=["title", "line"])
bool複数クエリを論理演算で組み合わせる(must/should/must_not など)。AND/OR条件で柔軟な検索(例: "教師" または "努力" を含む)query({"bool": {"should": [{"match": {"line": "教師"}}, {"match": {"line": "努力"}}], "minimum_should_match": 1}})
range数値や日付の範囲条件を指定。特定の範囲に絞って検索(例: 50〜100行目だけ表示query("range", line_number={"gte": 50, "lte": 100})
exists指定フィールドが存在するかどうかをチェック。空でないデータの抽出(例: "line_number" が存在する文だけ)query("exists", field="line_number")
wildcardワイルドカード(例: 坊っ*)での曖昧検索。部分一致検索をしたいとき(例: "坊っ" で始まる語を含む文)query("wildcard", line="坊っ*")


おわりに

今回は、RAGの文脈におけるElasticsearchの検索クエリについて、実装を交えて紹介しました。

  • 日本語の自然文に対して検索するには、Mappingの設計が重要
  • 検索には match, bool, match_phrase などを目的に応じて使い分ける
  • スコアは(デフォルト設定では)BM25により自動計算され、RAGの「文脈としての選定」に活用できる

次回は、Vector検索の紹介と、それとBM25を組み合わせたHybrid Searchについて解説していきます。
お疲れ様でした!

コメント