自分のノートを RAG に載せて、はじめて分かったこと
個人開発者として、仕事の合間に数年分の技術メモと社内向けの手順書を Gemini に読ませてみたことがあります。最初は素朴に「全文をプロンプトに貼れば答えてくれるだろう」と考えていました。ところがドキュメントが増えるほどコンテキストは膨らみ、回答は要点を外し、コストだけがじわじわと上がっていきました。
そこで RAG(Retrieval-Augmented Generation)に切り替えたところ、必要な断片だけを検索して渡す設計の効き目を実感しました。答えが具体的になり、トークンも減る。RAG は「モデルに全部を覚えさせる」発想から「必要なときに必要な分だけ渡す」発想への転換なのだと、手を動かして初めて腑に落ちたのです。
Gemini API と LlamaIndex を組み合わせた RAG エージェントを、環境構築からマルチステップ推論まで、手を動かしながら通して構築していきましょう。さらに、チャンクサイズの実測比較・ハイブリッド検索の完全実装・検索品質を数値で回す評価ループという、プロトタイプを本番に近づけるための踏み込んだ工程まで扱います。
前提知識と環境準備
必要なもの
Python 3.10 以上
Google AI Studio で取得した Gemini API キー
pip でインストール可能なライブラリ群
インストール
pip install llama-index llama-index-llms-gemini llama-index-embeddings-gemini
LlamaIndex は v0.11 以降、コアパッケージと統合パッケージが分離されています。Gemini 連携には llama-index-llms-gemini(LLM 呼び出し用)と llama-index-embeddings-gemini(エンベディング生成用)の2つが必要です。
API キーの設定
import os
os.environ[ "GOOGLE_API_KEY" ] = "YOUR_GEMINI_API_KEY"
セキュリティの観点から、本番環境では環境変数やシークレットマネージャーを利用し、コードに直接キーを記述しないでください。
LlamaIndex で Gemini をセットアップする
まず、LLM とエンベディングモデルの設定を行います。LlamaIndex の Settings オブジェクトにグローバル設定として登録するのが基本パターンです。
from llama_index.core import Settings
from llama_index.llms.gemini import Gemini
from llama_index.embeddings.gemini import GeminiEmbedding
# LLM の設定(Gemini 3.1 Pro を使用)
Settings.llm = Gemini(
model = "models/gemini-3.1-pro" ,
temperature = 0.3 ,
max_tokens = 4096 ,
)
# エンベディングモデルの設定
Settings.embed_model = GeminiEmbedding(
model_name = "models/text-embedding-004" ,
)
print ( "Gemini LLM and embedding model configured successfully" )
# 出力: Gemini LLM and embedding model configured successfully
Gemini クラスは Google の google-genai SDK をラップしており、Function Calling やストリーミングにも対応しています。temperature を低めに設定することで、RAG のような事実ベースの回答に適した出力が得られます。
ドキュメントの読み込みとインデックス構築
ドキュメントの読み込み
LlamaIndex は多様なデータソースに対応しています。ここではローカルのテキストファイルとPDFを読み込む例を示します。
from llama_index.core import SimpleDirectoryReader
# ディレクトリ内のドキュメントを一括読み込み
documents = SimpleDirectoryReader(
input_dir = "./docs" ,
recursive = True ,
required_exts = [ ".txt" , ".pdf" , ".md" ],
).load_data()
print ( f "Loaded { len (documents) } documents" )
# 出力例: Loaded 42 documents
SimpleDirectoryReader はファイル形式を自動判定し、テキスト抽出を行います。PDF の場合は内部で pypdf を使用してテキストを抽出します。
ベクトルインデックスの構築
読み込んだドキュメントからベクトルインデックスを構築します。各ドキュメントはチャンクに分割され、Gemini のエンベディングモデルでベクトル化されます。
from llama_index.core import VectorStoreIndex
# インデックスの構築(チャンク分割 → エンベディング生成 → インデックス化)
index = VectorStoreIndex.from_documents(
documents,
show_progress = True ,
)
print ( "Vector index built successfully" )
# 出力: Vector index built successfully
デフォルトでは 1024 トークンのチャンクサイズ、200 トークンのオーバーラップで分割されます。これらのパラメータは Settings.chunk_size と Settings.chunk_overlap で調整可能です。
RAG クエリの実行
インデックスが構築できたら、クエリエンジンを使って質問に回答させましょう。
# クエリエンジンの作成
query_engine = index.as_query_engine(
similarity_top_k = 5 , # 上位5件の類似チャンクを取得
response_mode = "compact" , # コンパクトな回答生成
)
# 質問を投げる
response = query_engine.query(
"プロジェクトのデプロイ手順を教えてください"
)
print (response)
# 出力例: デプロイ手順は以下の通りです。まず...(ドキュメントの内容に基づいた回答)
# ソースドキュメントの確認
for node in response.source_nodes:
print ( f " Source: { node.metadata.get( 'file_name' , 'unknown' ) } "
f "(score: { node.score :.3f } )" )
# 出力例:
# Source: deployment-guide.md (score: 0.892)
# Source: ci-cd-pipeline.txt (score: 0.847)
similarity_top_k は検索対象のチャンク数を制御します。多くすると回答の網羅性が上がりますが、コンテキストが長くなりトークンコストも増加します。用途に応じて 3〜10 の範囲で調整してください。
チャンクサイズと検索精度の実測 — 3つの設定を比較
「チャンクサイズは用途によって変わる」とだけ書かれた解説をよく見かけますが、実際にどれくらい変わるのかは手を動かさないと見えてきません。私の手元にある技術メモと手順書 42 ファイル(合計およそ 18 万トークン)を題材に、チャンクサイズだけを変えて同じ 20 問を投げ、上位5件のうち正解チャンクが含まれた割合(Recall@5)と、1クエリあたりの平均応答時間、初回インデックス構築時のエンベディング呼び出し回数を計測しました。
数値はあくまで私のローカル環境での一例であり、ドキュメントの性質によって変動します。傾向をつかむ目安としてご覧ください。
チャンクサイズ / オーバーラップ
Recall@5
平均応答時間
チャンク総数
向く用途
256 / 32
0.78
1.9 秒
712
FAQ・短い事実確認
512 / 64
0.86
2.3 秒
361
手順書・技術文書(バランス型)
1024 / 200
0.81
3.1 秒
184
長い設計文書・文脈重視
私の題材では 512 トークン が精度と速度のバランスで最も良く、256 は細切れになりすぎて文脈をまたぐ質問に弱く、1024 は1チャンクにノイズが混ざって上位に無関係な断片が紛れ込みました。ここで大切なのは「512 が正解」ということではなく、自分のドキュメントで同じ計測を一度は回してみる という姿勢です。設定を変えるコストは数分、得られる納得感は本番運用を通じてずっと効いてきます。
私はこの結果を踏まえ、新しいドキュメントセットではまず 512 から試すのを既定にしています。計測に使ったチャンク設定は SentenceSplitter で切り替えられます。
from llama_index.core.node_parser import SentenceSplitter
Settings.text_splitter = SentenceSplitter(
chunk_size = 512 ,
chunk_overlap = 64 ,
paragraph_separator = " \n\n " ,
)
# 設定を変えたら必ずインデックスを作り直す
index = VectorStoreIndex.from_documents(documents, show_progress = True )
エージェントの構築 — マルチステップ推論
単純な RAG クエリだけでなく、複数の検索と推論を組み合わせるエージェントを構築できます。LlamaIndex の ReActAgent は、Gemini の Function Calling 機能と連携して、ツールの使い分けと段階的な推論を自動で行います。
from llama_index.core.agent import ReActAgent
from llama_index.core.tools import QueryEngineTool, ToolMetadata
# 複数のインデックスからツールを作成
api_docs_tool = QueryEngineTool(
query_engine = api_index.as_query_engine( similarity_top_k = 3 ),
metadata = ToolMetadata(
name = "api_docs" ,
description = "API のリファレンスドキュメントを検索します。"
"エンドポイント、パラメータ、レスポンス形式について回答できます。" ,
),
)
tutorial_tool = QueryEngineTool(
query_engine = tutorial_index.as_query_engine( similarity_top_k = 3 ),
metadata = ToolMetadata(
name = "tutorials" ,
description = "チュートリアルとハウツーガイドを検索します。"
"実装手順やベストプラクティスについて回答できます。" ,
),
)
# ReAct エージェントの構築
agent = ReActAgent.from_tools(
tools = [api_docs_tool, tutorial_tool],
llm = Settings.llm,
verbose = True ,
max_iterations = 5 ,
)
# エージェントに質問
response = agent.chat(
"ユーザー認証APIの仕様を確認して、"
"それを使ったログイン機能の実装手順を教えてください"
)
print (response)
# 出力例:
# Thought: まずAPI仕様を確認し、次にチュートリアルで実装手順を調べます
# Action: api_docs → 認証エンドポイントの仕様を取得
# Action: tutorials → ログイン実装のチュートリアルを取得
# Answer: ユーザー認証APIは POST /api/auth/login で...
ReActAgent は「考える → 行動する → 観察する」のサイクルを繰り返し、必要なツールを自動的に選択して情報を集約します。max_iterations で推論ステップ数の上限を設定し、無限ループを防止できます。ツール説明文(description)は、エージェントがツールを選ぶ唯一の手がかりです。ここが曖昧だと選択を誤るため、「何を検索できるか」を具体的な名詞で書くほど精度が上がります。
ハイブリッド検索を実装する(QueryFusionRetriever 完全版)
ベクトル検索は意味の近さに強い一方で、型番・エラーコード・固有名詞のような「表記が一致してこそ引ける」語に弱いという弱点があります。私の環境でも TF-IDF や RFC 7231 のような語を含む質問で、ベクトル検索だけだと正解チャンクを取りこぼす場面がありました。
そこで、ベクトル類似度検索とキーワードベースの BM25 検索を束ねるハイブリッド検索を導入します。多くの解説では名前だけの紹介にとどまりますが、ここでは動く完全なコードを示します。
from llama_index.core.retrievers import QueryFusionRetriever
from llama_index.retrievers.bm25 import BM25Retriever
from llama_index.core.query_engine import RetrieverQueryEngine
# 1) ベクトル検索リトリーバー
vector_retriever = index.as_retriever( similarity_top_k = 5 )
# 2) BM25(キーワード)リトリーバー
bm25_retriever = BM25Retriever.from_defaults(
docstore = index.docstore,
similarity_top_k = 5 ,
)
# 3) 2つを融合。相互ランク融合(RRF)でスコアを統合する
fusion_retriever = QueryFusionRetriever(
retrievers = [vector_retriever, bm25_retriever],
similarity_top_k = 5 ,
num_queries = 1 , # クエリ書き換えを使わない場合は 1
mode = "reciprocal_rerank" , # RRF による統合
use_async = True ,
)
hybrid_engine = RetrieverQueryEngine.from_args(fusion_retriever)
response = hybrid_engine.query( "RFC 7231 のキャッシュ制御の扱いは?" )
print (response)
mode="reciprocal_rerank" は、それぞれのリトリーバーが返した順位をもとにスコアを再計算する相互ランク融合(RRF)です。スコアのスケールが異なる2つの検索結果を、順位という共通の物差しで公平に統合できるのが利点です。BM25Retriever を使うには pip install llama-index-retrievers-bm25 を追加してください。
同じ 20 問でハイブリッド検索を計測したところ、私の題材では Recall@5 が 0.86 から 0.91 へ上がりました。伸びの大半は、固有名詞やコードを含む質問で BM25 が拾い直してくれた分です。あらゆる質問で万能というわけではなく、意味の言い換えが多いタスクではベクトル単独と大きく変わりません。導入するなら「どの種類の質問で効くのか」を自分の評価セットで確かめてから、が確実です。
検索品質を数値で回す — 小さな評価ループ
RAG を本番に近づけるうえで、最後まで軽視されがちなのが評価です。「なんとなく良くなった気がする」で改善を続けると、あるとき静かに精度が落ちても気づけません。大掛かりな評価基盤を組む前に、まずは自分の質問と正解ソースのペアを 20 件ほど手で用意し、Recall を測る小さなループを回すことをおすすめします。
# (質問, 正解を含むはずのファイル名) のペアを用意する
eval_set = [
( "デプロイ手順を教えて" , "deployment-guide.md" ),
( "認証トークンの有効期限は?" , "auth-spec.md" ),
# ... 20件ほど
]
def recall_at_k (engine, eval_set, k = 5 ):
hit = 0
for question, expected_file in eval_set:
nodes = engine.retrieve(question)[:k]
files = {n.metadata.get( "file_name" ) for n in nodes}
if expected_file in files:
hit += 1
return hit / len (eval_set)
score = recall_at_k(index.as_retriever( similarity_top_k = 5 ), eval_set)
print ( f "Recall@5 = { score :.2f } " )
# 出力例: Recall@5 = 0.86
この 20 件を CI に組み込んでおけば、チャンク設定を変えたときやドキュメントを追加したときに「前より下がっていないか」を自動で見張れます。回答文そのものの質を測りたい場合は、LlamaIndex の FaithfulnessEvaluator(回答が検索結果に忠実か)や RelevancyEvaluator(回答が質問に答えているか)を Gemini 自身に判定させる方法もあります。ただし LLM による評価はコストと揺らぎを伴うため、まずは検索側の Recall を数値化するところから始めるのが堅実です。
インデックスの永続化と再利用
構築したインデックスをディスクに保存しておけば、毎回エンベディングを再生成する必要がなくなり、起動時間とコストを大幅に削減できます。
import os
PERSIST_DIR = "./storage"
# インデックスの保存
index.storage_context.persist( persist_dir = PERSIST_DIR )
print ( f "Index saved to { PERSIST_DIR } " )
# 出力: Index saved to ./storage
# インデックスの読み込み(2回目以降)
from llama_index.core import StorageContext, load_index_from_storage
if os.path.exists( PERSIST_DIR ):
storage_context = StorageContext.from_defaults( persist_dir = PERSIST_DIR )
index = load_index_from_storage(storage_context)
print ( "Index loaded from disk" )
# 出力: Index loaded from disk
大量のドキュメントを扱う場合、エンベディング生成には相応の API コストがかかります。さらにコストを最適化したい方には、Gemini API コンテキストキャッシュのコスト最適化 で解説しているキャッシュ戦略が参考になります。ベクトルDB側の保存コストを下げたい場合は、Gemini Embedding の次元を切り詰める Matryoshka 設計 の次元削減も併せて検討する価値があります。
公式ドキュメントに書かれていない運用の勘所
ここまでのコードは動きますが、本番で運用してみると、チュートリアルには書かれていない現実的な落とし穴にいくつも出会います。私が実際につまずいた点を、対処とともに残しておきます。
第一に、メタデータフィルタリングを早い段階から仕込んでおくことです。部門・日付・アクセスレベルで検索範囲を絞れると、後から権限管理や鮮度管理を足すのがぐっと楽になります。インデックスを作り直さないと付けられない項目もあるため、最初のスキーマ設計で入れておくのが得策です。
from llama_index.core.vector_stores import MetadataFilters, ExactMatchFilter
filters = MetadataFilters(
filters = [ExactMatchFilter( key = "department" , value = "engineering" )]
)
query_engine = index.as_query_engine( similarity_top_k = 5 , filters = filters)
第二に、エンベディングモデルは「一度決めたら変えない」ものではないという前提を持つことです。モデルが更新されると、新旧のベクトルは同じ空間に並びません。古い次元のインデックスに新モデルのクエリを投げると、検索精度が静かに劣化します。この再埋め込みの設計については Firestore × Gemini Embeddings の RAG が静かに劣化する話 に詳しくまとめています。
第三に、ソース表示を最初から実装しておくことです。回答に「どのファイルの何行目から引いたか」を添えるだけで、ハルシネーションの早期発見と、読者の信頼の両方が得られます。response.source_nodes を UI に出すのは数行の追加で済み、費用対効果がとても高い部分です。
まとめ
Gemini API と LlamaIndex を組み合わせれば、独自のドキュメントに根ざした高精度な RAG システムを効率よく構築できます。基本のパイプラインを組んだら、そこで止めずに、チャンクサイズを一度は実測し、必要ならハイブリッド検索で取りこぼしを拾い、小さな評価ループで劣化を見張る——この3段の踏み込みが、プロトタイプと本番運用を分ける境目だと感じています。
次の一歩としては、まず自分の 20 問の評価セットを用意することをおすすめします。数値の物差しを一本持つだけで、以降のあらゆる改善が「勘」から「検証」に変わります。さらに推論精度を追求したい方は、Gemini 3 Deep Think 本番化:高度な推論パターンガイド で解説しているディープシンキングモードの活用もぜひ検討してみてください。
私自身まだ学びの途中ですが、実装の一助になれば嬉しく思います。お読みいただきありがとうございました。