2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Gemini 3 ProとOpenSearchで高性能RAG(全文検索+ベクトル検索)を構築

2
Last updated at Posted at 2025-12-26

はじめに

目標は、ハイブリッドRAG(全文検索+ベクトル検索)を構築することです。

リポジトリ

この記事で使用するリポジトリは以下からご参照ください。

ソースPDF

RAGに登録する文書として、以下のPDFを使用しています。
https://www.city.kobe.lg.jp/documents/15123/r5_doukou.pdf
https://www.fsa.go.jp/news/r4/hoken/20230630-2/01.pdf

目次

やりたいこと

  • ローカルで動く 完全無料のハイブリッドRAG を作る
  • 社内PDFなどをテキスト化して、
    • キーワード検索(BM25)
    • ベクトル検索(Embedding)
      を組み合わせて、精度の高い検索+要約をしたい

ハイブリッドRAGのコンポーネント

  • OpenSearch
    • OSS の検索エンジン(Apache 2.0)
    • 全文検索(BM25)とベクトル検索の両方に対応
    • Docker でローカル起動すれば利用料金はゼロ
  • Gemini API
    • Google AI Studio のAPI
    • テキスト埋め込みに gemini-embedding-001
    • 回答生成に gemini-3-pro-preview を利用

この 2 つを組み合わせて、ローカルで完結するハイブリッドRAGを構築しています。

ハイブリッドRAGとは

RAGは通常ベクトル検索のみを行って文書を引っ張ってきます。
複数の検索結果(今回はベクトル検索+全文検索)から再ランクを行うことで、精度が高まるようです。
OpenSearchはこのハイブリッド検索を可能にします。

ディレクトリ構成

/rag_project/rag_opensearch/ ディレクトリを使用します。
その他 /rag_project/rag_evaluate/ /rag_project/pdf2md/ については別の記事でご紹介します。

rag_project/
├─ .env                     # GEMINI_API_KEY
├─ docs/                    # 入力PDFを置くディレクトリ(01.pdf, r5_doukou.pdf など)
├─ pdf2md/
├─ rag_evaluate/
├─ rag_opensearch/
│  ├─ docker-compose.yml    # OpenSearch を Docker で起動するための設定ファイル
│  ├─ embedding_models.py   # Gemini Embedding API のラッパー。文書/クエリのベクトル化を担当
│  ├─ index_documents.py    # rag_opensearch/ocr_tesseract/*.txt をチャンク分割し、Embedding を計算して OpenSearch に登録
│  ├─ llm_models.py         # Gemini LLM への問い合わせラッパー。GeminiRAGModel が RAG 用の回答生成を担当
│  ├─ ocr_tesseract/        # OCR で作成したテキストファイルを置くディレクトリ
│  │  ├─ 01.txt
│  │  └─ r5_doukou.txt
│  ├─ outputs/              # 今後の出力用ディレクトリ(現状は未使用)
│  ├─ rag_opensearch.py     # OpenSearch + Gemini Embedding による RAG 検索ロジックと動作確認用 main
│  └─ requirements.txt      # rag_opensearch 用の Python ライブラリ一覧(OpenSearch, Gemini, OCR など)
├─ config.py                # OpenSearch や RAG/LLM/OCR の設定値(ホスト・ポート・インデックス名・top_k・パスなど)
└─ (その他)

セットアップ

前提

  • Docker が動く環境
  • Python 3 系
  • Google AI Studio で取得した Gemini API キー

1. 仮想環境と依存パッケージ

git clone https://github.com/heyho99/rag_project
cd rag_project

python3 -m venv venv
source venv/bin/activate  # Windowsなら: venv\Scripts\activate

pip install -r rag_opensearch/requirements.txt

2. PDFをダウンロード

wget -O docs/01.pdf https://www.fsa.go.jp/news/r4/hoken/20230630-2/01.pdf
wget -O docs/r5_doukou.pdf https://www.city.kobe.lg.jp/documents/15123/r5_doukou.pdf

3. OpenSearch を起動

docker-compose up -d

4. Gemini API キーを設定

プロジェクトルートの .env に API キーを書きます。

vi .env
GEMINI_API_KEY=your_api_key_here

5. config.py を設定

rag_opensearch/config.py で、インデックス名や OCR の入出力パスなどを確認・必要に応じて変更します。

rag_opensearch/config.py
config.py
# 省略...

# --- Tesseract OCR ---
TESSERACT_OCR_LANG = "jpn"
TESSERACT_OCR_DPI = 300

# =============================================================================
# OpenSearch / RAG設定(rag_opensearch)
# =============================================================================

# --- OpenSearch接続 ---
OPENSEARCH_HOST = "localhost"
OPENSEARCH_PORT = 9200

# --- Embedding ---
EMBEDDING_DIM = 1536
GEMINI_EMBEDDING_MODEL_NAME = "gemini-embedding-001"

# --- チャンク分割 ---
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
CHUNK_OUTPUT_DIR = "outputs/chunks"

# --- RAG検索 ---
RAG_TOP_K = 2
RRF_RANK_CONSTANT = 60

# --- RAG用LLM ---
RAG_LLM_MODEL_NAME = "gemini-3-pro-preview"
RAG_LLM_THINKING_LEVEL = "LOW"

# 使用するインデックス名(変換方式に合わせて変更)
# INDEX_NAME = "tesseract-txt"
# INDEX_NAME = "pdfplumber-txt"
INDEX_NAME = "gemini-md"

# インデックス対象ファイルパターン(INDEX_NAMEに合わせて変更)
# INDEX_FILE_PATTERNS = ["rag_opensearch/ocr_tesseract/*.txt"]
# INDEX_FILE_PATTERNS = [f"{TESSERACT_TXT_OUTPUT_DIR}/*.txt"]
# INDEX_FILE_PATTERNS = [f"{PDFPLUMBER_TXT_OUTPUT_DIR}/*.txt"]
INDEX_FILE_PATTERNS = [f"{GEMINI_MD_OUTPUT_DIR}/*.md"]

# ...省略

以降の OCR・インデックス作成・RAG 実行は、この設定に従って動きます。

OCR で PDF をテキスト化

まずは、docs/ 配下の PDF を OCR してテキスト化し、rag_opensearch/ocr_tesseract/に出力されます。

  • 入力: docs/01.pdf, docs/r5_doukou.pdf など
  • 出力: rag_opensearch/ocr_tesseract/01.txt など

実行コマンド:

python -m rag_opensearch.ocr_tesseract

これで、RAG のインデックス対象となるテキストファイルが揃います。

インデックス作成

rag_opensearch/ocr_tesseract/ 配下のテキストを OpenSearch に登録します。

  • 日本語向けに段落や改行、「。」などを意識したチャンク分割
  • 各チャンクを Gemini Embedding でベクトル化
  • OpenSearch のインデックス(config.pyRAG_INDEX_NAME)に一括登録

実行コマンド:

docker ps # openseachコンテナが起動していることを確認

python -m rag_opensearch.index_documents

検索とRAG実行

rag_opensearch.py では、次のような流れで検索&回答生成を行います。

  • クエリを Gemini でベクトル化
  • OpenSearch の Hybrid Search で BM25 と kNN を同時に実行
  • RRF(Reciprocal Rank Fusion)でスコアを統合
  • 上位チャンクをコンテキストとして Gemini に投げ、最終回答を生成

サンプル実行コマンド:

python -m rag_opensearch.rag_opensearch

スクリプト内にあらかじめ用意した日本語の質問がいくつか流れ、

  • 取得されたチャンク一覧
  • RRFランクとスコア
  • LLM の最終回答

がターミナルに表示されます。

RAG 実行コマンドの出力

✅ RRF検索パイプライン 'tesseract-txt-rrf-pipeline' を作成しました


=== 質問: 「持続可能なビジネスモデル」って、保険会社はどんな取組みをすればいいとされてるの? ===

設定: rank_constant=60

【取得チャンク】
1. file: 01.txt | chunk: 1 | page: null
   RRF_rank: 1 | score: 0.032787
   内容: 保険の加入チャネルの状況>
本事務年度の主な実績

【生命保険会社】                                        営業職員 88.5%     71.896     ...

2. file: 01.txt | chunk: 0 | page: null
   RRF_rank: 2 | score: 0.032522
   内容: --- ページ 1 ---
2023年 6 月

保険モニタリングレポート【概要】

--- ページ 2 ---
4ル』 はじめに

保険会社の社会的役割...


【回答】
提供された情報によると、保険会社が「持続可能なビジネスモデル」を構築するために求められている取組みは以下の通りです。

*   デジタル化を活用した効率的な業務運営(ソース: 01.txt, ページ: 3)
*   顧客ニーズの変化に即した商品開発(ソース: 01.txt, ページ: 3)
*   営業職員チャネルを持続可能なものにするための、営業職員の採用・育成の見直し(ソース: 01.txt, ページ: null)
*   営業活動におけるデジタルの活用(ソース: 01.txt, ページ: null)
*   損害保険(特に火災保険

重要なポイント

  • Embedding はクライアント側(Python + Gemini API)で計算し、OpenSearch は「保存して検索するだけ」にしている
  • ハイブリッド検索は OpenSearch の Hybrid Search + RRF 機能に乗るだけにして、実装側はシンプルに保つ
  • インデックス名や top_k、LLM のモデル名・思考レベルなどは config.py に集約し、コードは「設定を読むだけ」にしている

まとめ

  • OpenSearch(Docker)と Gemini API を組み合わせて、検索(BM25 + ベクトル + RRF)と回答生成をローカルで一気通貫できるようにした
  • コードは「登録(index_documents.py)」と「検索&RAG(rag_opensearch.py)」に分け、設定は config.py に集約している
  • まずは最小構成で動かして、用途に合わせて top_k / chunk_size / thinking_level などを少しずつ調整していくのが現実的だと思う

以下実装内容をもう少し解説

ここからは、実装の中身を少しだけ補足します。コード全文は GitHub を見てもらう前提で、要点だけ箇条書きで整理します。

1. インデックス作成(index_documents.py

  • DocumentIndexer がテキストファイルを読み込み、OpenSearch に登録します。
  • 日本語テキストを RecursiveCharacterTextSplitter で分割し、
    • 段落
    • 改行
    • 「。」などの句読点
      を優先しつつ、CHUNK_SIZE / CHUNK_OVERLAP を守るようにチャンク化しています。
  • 各チャンクに対して GeminiEmbedding.embed_batch() でベクトルを計算し、
    • content
    • source(ファイルパス)
    • filename
    • chunk_index
    • embedding(ベクトル本体)
      をまとめて OpenSearch に bulk インデックスしています。
  • インデックスのマッピングでは、embedding フィールドを knn_vector 型にし、EMBEDDING_DIM(1536 次元)と揃えています。
# index_documents.py より抜粋(インデックスのマッピング)
index_body = {
    "settings": {
        "index": {
            "knn": True,
            "number_of_shards": 2,
        }
    },
    "mappings": {
        "properties": {
            "content": {"type": "text"},
            "source": {"type": "keyword"},
            "filename": {"type": "keyword"},
            "chunk_index": {"type": "integer"},
            "embedding": {
                "type": "knn_vector",
                "dimension": self.embedding_dim,
            },
        }
    },
}
self.client.indices.create(index=self.index_name, body=index_body)

# 日本語向けのチャンク分割設定
splitter = RecursiveCharacterTextSplitter(
    chunk_size=CHUNK_SIZE,
    chunk_overlap=CHUNK_OVERLAP,
    separators=["\n\n", "\n", "", "", "", " ", ""],
    length_function=len,
    is_separator_regex=False,
)

2. 検索とRAG(rag_opensearch.py

  • BaseOpenSearchRAG が共通の枠を持ちます。
    • OpenSearch クライアントの初期化
    • クエリの埋め込み生成(RETRIEVAL_QUERY
    • 検索結果の整形(_format_results
    • 取得チャンクを使った回答生成(generate_answer
  • このサンプルでは、ハイブリッド検索方式として RRF(Reciprocal Rank Fusion) だけを採用しています。
    • RRFOpenSearchRAG が、RRF 用の search pipeline を /_search/pipeline/... に対して作成
    • 検索クエリは OpenSearch の hybrid クエリをほぼそのまま使用し、
      • match(BM25)
      • knn(ベクトル検索)
        を同時に投げています。
  • answer() メソッドは
    1. ハイブリッド検索で上位 k 件のチャンクを取得
    2. その内容をまとめて Gemini に渡し、最終回答テキストを生成
    3. 取得チャンクのメタ情報も含めて返却
      という形で、検索〜回答生成までを 1 ステップにまとめています。
# rag_opensearch.py より抜粋(RRF パイプライン定義)
pipeline_body = {
    "description": "Post processor for hybrid RRF search",
    "phase_results_processors": [
        {
            "score-ranker-processor": {
                "combination": {
                    "technique": "rrf",
                    "rank_constant": self.rank_constant,
                }
            }
        }
    ],
}
self.client.transport.perform_request(
    "PUT",
    f"/_search/pipeline/{self.search_pipeline_name}",
    body=pipeline_body,
)

# Hybrid Query + RRF で検索
search_body = {
    "size": k,
    "_source": {"exclude": ["embedding"]},
    "query": {
        "hybrid": {
            "queries": [
                {"match": {"content": query}},
                {
                    "knn": {
                        "embedding": {
                            "vector": query_embedding,
                            "k": k,
                        }
                    }
                },
            ]
        }
    },
}
response = self.client.search(
    index=self.index_name,
    body=search_body,
    params={"search_pipeline": self.search_pipeline_name},
)

3. Embedding 周り(embedding_models.py

  • GeminiEmbedding は LangChain などを使わず、Google の公式クライアントを直接叩く薄いラッパーです。
  • embed_text() / embed_batch() の 2 つだけを定義し、
    • 単一テキスト用(検索クエリ)
    • バッチ用(インデックス時の大量チャンク)
      に分けています。
  • 埋め込み次元が 3072 未満の場合は L2 正規化してから保存しており、
    ベクトル間の類似度計算が安定しやすいようにしています。
  • クォータ制限(429 / RESOURCE_EXHAUSTED)を検知した場合は、
    • 一括 → 1 件ずつ
    • 60 秒待機してリトライ
      といったフェイルセーフも入れています。
# embedding_models.py より抜粋(埋め込み生成)
result = self.client.models.embed_content(
    model=self.model,
    contents=texts,
    config=types.EmbedContentConfig(
        task_type=task_type or self.task_type,
        output_dimensionality=self.output_dimensionality,
    ),
)
embeddings = [list(e.values) for e in result.embeddings]

# 3072次元未満のときは正規化してから保存
if self.output_dimensionality < 3072:
    embeddings = [self._normalize(emb).tolist() for emb in embeddings]

4. LLM 周り(llm_models.py

Googleの公式の例の通りに実装しています。

# llm_models.py より抜粋(RAG 用の応答生成)
contents = [
    types.Content(
        role="user",
        parts=[types.Part.from_text(text=prompt)],
    ),
]
generate_content_config = types.GenerateContentConfig(
    thinking_config=types.ThinkingConfig(thinking_level=self.thinking_level),
)
chunks = []
for chunk in self.client.models.generate_content_stream(
    model=self.model_name,
    contents=contents,
    config=generate_content_config,
):
    chunks.append(chunk.text or "")
return "".join(chunks)

5. 設定の集約(config.py

  • OpenSearch / Gemini / RAG に関するパラメータはすべて config.py に置いています。
    • 接続情報: OPENSEARCH_HOST, OPENSEARCH_PORT
    • インデックス名・ top_k など: RAG_INDEX_NAME, RAG_TOP_K, RRF_RANK_CONSTANT
    • チャンク分割: CHUNK_SIZE, CHUNK_OVERLAP, INDEX_FILE_PATTERNS
    • LLM 設定: RAG_LLM_MODEL_NAME, RAG_LLM_THINKING_LEVEL
  • 実装側は基本的に「config から値を読むだけ」にしており、
    • 実験でパラメータを変えたいとき
    • インデックス名を変えたいとき
      も、config.py を書き換えれば済むような構成にしています。
# config.py より抜粋
OPENSEARCH_HOST = "localhost"
OPENSEARCH_PORT = 9200

EMBEDDING_DIM = 1536
RAG_INDEX_NAME = "tesseract-txt"

RAG_TOP_K = 5
RRF_RANK_CONSTANT = 60

CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
INDEX_FILE_PATTERNS = [
    "rag_opensearch/ocr_tesseract/*.txt",
]

GEMINI_EMBEDDING_MODEL_NAME = "gemini-embedding-001"
RAG_LLM_MODEL_NAME = "gemini-3-pro-preview"
RAG_LLM_THINKING_LEVEL = "HIGH"

OCR_INPUT_PATTERN = "docs/*.pdf"
OCR_OUTPUT_DIR = "rag_opensearch/ocr_tesseract"
OCR_LANG = "jpn"
OCR_DPI = 300
2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?