【実践】 ElasticSearch × RAG 入門 (2) – ElasticSearchのRAG用 データベースの作り方

第1回ではElasticSearchの簡単な概要について説明しました。
この記事では夏目漱石:「坊っちゃん」のElasticSearch Index(データベース)を作成して、検索できるようになるまでを解説します。

1. ElasticSearchでRAG用データベースを構築する意義

前回のおさらいになります。
RAGで用いる検索エンジンにはいくつか選択肢がありました:

検索エンジンRAG用途での適合性長所短所
ElasticSearch⭐⭐⭐⭐⭐全文検索に最適化、豊富な言語解析、スケーラビリティ高リソース消費が大きい
ベクトルDB (Faiss, Milvus)⭐⭐⭐⭐セマンティック検索に特化、高速テキスト検索機能が限定的
RDB + 全文検索拡張⭐⭐⭐既存システムとの統合が容易高度な言語処理が限定的

ElasticSearchではRAGの検索に必要な機能は全て1つにまとまっているので環境構築が楽です。

2. ElasticSearchの環境構築

まずは環境構築を行いましょう。

(1) Docker と Docker Compose がインストールされていることを確認

docker --version
docker-compose --version

(2) プロジェクトディレクトリを作成

mkdir elasticsearch-demo
cd elasticsearch-demo

(3) dockerfile と docker-compose.yml ファイルを作成
以下の2つのファイルを作成し、同じフォルダに配置してください。

# dockerfile
FROM docker.elastic.co/elasticsearch/elasticsearch:8.18.0
RUN bin/elasticsearch-plugin install analysis-kuromoji
# docker-compose.yml
version: "3.8"

services:
  elasticsearch:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "9200:9200"
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - es_data:/usr/share/elasticsearch/data
  kibana:
    image: docker.elastic.co/kibana/kibana:8.18.0
    environment:
      ELASTICSEARCH_HOSTS: http://elasticsearch:9200
    ports:
      - 5601:5601

volumes:
  es_data:
    driver: local

(4) Docker Compose でコンテナを起動

docker-compose up -d

上記のコマンドで、ElasticsearchとKibanaのコンテナが起動します。「-d」オプションでバックグラウンド実行します。

(5) 動作確認
起動から少し時間をおいてから、以下のコマンドでElasticsearchの状態を確認します:

curl http://localhost:9200

(6) 必要なPythonライブラリのインストール
今回は elasticsearch-dsl を用いて、pythonコードで完結したElasticSearchの環境構築を行います。

elasticsearch-dslは、PythonでElasticsearchの検索クエリの構築、インデックスの定義、マッピングの管理、ドキュメントの操作などを、より宣言的かつ直感的に行えるようにします。

Elasticsearch DSL — Elasticsearch DSL 8.18.0 documentation

以下のpythonライブラリをインストールしてください。

dependencies = [
    "elasticsearch==8.11.0",
    "elasticsearch-dsl==8.11.0",
]

3. 「坊っちゃん」テキストのインデックス化手順

早速 elasticsearch-dslを用いて、Indexを作成していきましょう。

以下のpythonスクリプトを実行してみてください:

from tqdm import tqdm

from elasticsearch_dsl import Document, Text, Integer, connections, analyzer, Index
from elasticsearch.helpers import bulk

INDEX_NAME = "botchan"
TEXT_DATA_PATH = "<path to botchan.txt>"  # https://www.aozora.gr.jp/cards/000148/files/752_14964.html からテキストデータをダウンロードしてください 

# Elasticsearchに接続
es = connections.create_connection(hosts=["http://localhost:9200"], timeout=20)


# 0. Analyzerの定義
ja_analyzer = analyzer(
    "ja_analyzer",
    tokenizer="kuromoji_tokenizer",
    filter=["kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop"],
)

# 1. Mappingの定義 (≒ スキーマの定義)
class BotchanLine(Document):
    line = Text(analyzer=ja_analyzer)
    line_number = Integer()

    class Index:
        name = INDEX_NAME


# 2. Indexの作成
if es.indices.exists(index=INDEX_NAME):
    es.indices.delete(index=INDEX_NAME)
    print(f"Index {INDEX_NAME} deleted.")

index = Index(INDEX_NAME)
index.settings(
    number_of_shards=1,
    number_of_replicas=0,
    analysis={
        "analyzer": {
            "ja_analyzer": {
                "type": "custom",
                "tokenizer": "kuromoji_tokenizer",
                "filter": ["kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop"],
            }
        }
    },
)

index.document(BotchanLine)
index.create()

# 3. 「坊っちゃん」のデータをドキュメントとして登録する
docs = []

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

bulk(es, docs, index=INDEX_NAME)

順番に解説していきます。

3-1. 「0. Analyzerの定義」

Elasticsearchで日本語のテキストを効果的に検索するためには、適切なアナライザーを使用することが重要です。
日本語は英語と違い、単語の区切りが明確ではなく、活用形も複雑なため、特別な処理が必要になります。

アナライザーは以下の3つの処理を行います:
入力テキスト → 文字フィルター → トークナイザー → トークンフィルター → インデックス登録/検索用トークン

  • 文字フィルター: 特定の文字を置換または削除します(例:HTML特殊文字の処理)
  • トークナイザー: テキストをトークン(単語)に分割します
  • トークンフィルター: 分割されたトークンを加工します(例:小文字化、ストップワード除去)
ja_analyzer = analyzer(
    "ja_analyzer",
    tokenizer="kuromoji_tokenizer",
    filter=["kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop"],
)

ここでは、以下のようなkuromojiプラグインを導入しています:

  • kuromoji_tokenizer: 日本語テキストを適切に分割するための形態素解析器です。「私は学校に行きました」を「私」「は」「学校」「に」「行き」「まし」「た」のように分解します。
  • kuromoji_baseform: 活用形を基本形に変換します。例えば「行きました」→「行く」のように変換し、異なる活用形でも同じ単語として検索できるようにします。
  • kuromoji_part_of_speech: 品詞情報を利用したフィルタリングができるようにします。
  • ja_stop: 「は」「が」「の」などの日本語のストップワード(検索に重要でない一般的な単語)を除外します。

analyzerやtokenizerの動作確認をしたい場合には、以下のようなcurlコマンドをターミナルで叩きます:

# analyzerの動作確認
curl -X GET "localhost:9200/botchan/_analyze" -H 'Content-Type: application/json' -d'
{
  "analyzer": "ja_analyzer",
  "text": "坊っちゃん、走れ!"
}'

# tokenizerの動作確認
curl -X GET "localhost:9200/botchan/_analyze" -H 'Content-Type: application/json' -d'
{
  "analyzer": "kuromoji_tokenizer",
  "text": "私は学校に行きました"
}'
`

3-2. 「Mappingの定義」

Mappingは、RDBでいうテーブル定義に相当します。各フィールドのデータ型や検索方法を指定します。

class BotchanLine(Document):
    line = Text(analyzer=ja_analyzer)
    line_number = Integer()

    class Index:
        name = INDEX_NAME

ここでは:

  • Documentクラスを継承して、「坊っちゃん」の各行を表すドキュメント型を定義しています。
  • line: テキスト本文を格納するフィールドで、先ほど定義した日本語アナライザーを使用します。
  • line_number: 行番号を整数型で格納します。
  • class Index: ドキュメントが属するインデックス名を指定します。

Elasticsearchはスキーマレスですが、明示的にマッピングを定義することで、データの取り扱い方を最適化できます。

3-3. 「Indexの作成」

Indexは、Elasticsearchのデータ構造の単位で、RDBのデータベースに相当します。

if es.indices.exists(index=INDEX_NAME):
    es.indices.delete(index=INDEX_NAME)
    print(f"Index {INDEX_NAME} deleted.")

index = Index(INDEX_NAME)
index.settings(
    number_of_shards=1,
    number_of_replicas=0,
    analysis={
        "analyzer": {
            "ja_analyzer": {
                "type": "custom",
                "tokenizer": "kuromoji_tokenizer",
                "filter": ["kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop"],
            }
        }
    },
)

index.document(BotchanLine)
index.create()

ここでは:

  • 既存のインデックスがあれば削除します(再実行時の対応)。
  • number_of_shards=1: シングルノード構成のため、シャード数を1に設定。
  • number_of_replicas=0: レプリカは作成しない設定。
  • analysis: アナライザーの設定を再度明示的に指定しています。これにより、インデックスレベルでアナライザーが正しく構成されます。
  • index.document(BotchanLine): 先ほど定義したマッピングをインデックスに適用します。
  • index.create(): 実際にインデックスを作成します。

3-4 「坊っちゃんのデータをドキュメントとして登録」

最後に、テキストファイルからデータを読み込み、Elasticsearchにドキュメントとして登録します。

docs = []

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

bulk(es, docs, index=INDEX_NAME)

ここでは:

  • テキストファイルを1行ずつ読み込みます。
  • 各行に対してBotchanLineオブジェクトを作成し、IDとして行番号を設定します。
  • docsリストに全ドキュメントを格納した後、bulk関数を使って一括登録します。

一括登録(bulk)を使用することで、1件ずつ登録するよりも大幅にインデックス作成のパフォーマンスが向上します。処理の進捗状況はtqdmライブラリで可視化しています。

3-5 検索の動作確認

最後に、「坊っちゃん」というキーワードで簡単な検索を実行して、動作確認してみます:

curl -X GET "http://localhost:9200/botchan/_search" -H 'Content-Type: application/json' -d '
{
  "query": {
    "match": {
      "line": "坊っちゃん"
    }
  }
}'

検索結果が返ってきたらIndexの作成に成功しています🎉

おわりに

お疲れ様でした。
今回は「坊っちゃん」を例にして、ElasticSearchを使ったRAG用データベースの構築方法を解説しました。
適切なインデックス設計、特に日本語のテキスト解析設定が重要なポイントです。


次回はElasticSearchでの複雑なクエリを用いた検索方法を紹介していきます。それでは!

【実践】ElasticSearch × RAG 入門 (3) – RAGのための様々な検索をしてみよう
前回は、青空文庫「坊っちゃん」を素材として、Elasticsearchのインデックスを作成しました。今回はこのインデックスを使って、RAG(Retrieval-Augmented Generation)に活用できるような、具体的な検索クエリ...

コメント