RAG(検索拡張生成)システムのPoC(概念実証)において、「精度こそ正義」と信じ込み、数万件の技術文書をすべて最高精度の埋め込みモデルでベクトル化し、ユーザーの質問に対して常に大量のコンテキストをLLMに投げ込む設計にしてしまうケースは少なくありません。
しかし、月末になってクラウドインフラやLLM APIの請求額を見て、桁が一つ間違っているのではないかと青ざめることになります。たった1ヶ月のPoCで、年間のIT予算の大部分を食いつぶしてしまうような事態です。
精度は出ていても、経済合理性が完全に欠落していれば、それは「素晴らしい回答を出すたびに会社の利益が消えていくシステム」に過ぎません。実務の現場では、このようなコスト管理の失敗が頻繁に起きています。
あなたは今、同じ罠に陥りかけていませんか?
「とりあえず全データをチャンク分割してベクトルDBに入れればいい」
もしそう考えているなら、一度立ち止まってください。RAGの運用コスト、特にLLMのトークン課金とベクトル検索の計算リソースを劇的に削減しながら、むしろ回答精度を向上させるアーキテクチャが存在します。それが「階層的インデックス(Hierarchical Indices)」です。
今回は、LlamaIndex(v0.10.x系)を用いたこの階層的アプローチの実装方法を、具体的なコードと共にステップバイステップで共有します。これは単なる「節約術」ではありません。システム全体のパフォーマンスを最適化し、ビジネスへの最短距離を描くための、エンジニアリングの正攻法です。
なぜ「階層化」がRAGのコスト削減に効くのか
まず、なぜ従来の「フラットなベクトル検索」がコスト高になるのか、その構造的な欠陥を数字で見てみましょう。
フラットなベクトル検索の限界とコスト構造
一般的なRAGの実装では、ドキュメントを一定の長さ(例えば512トークン)でチャンク分割し、それらをすべて単一のインデックスに格納します。質問に対して上位k個(Top-k)を取得し、LLMに渡します。
ここで問題になるのが「ノイズ」と「トークン消費」です。
例えば、ユーザーが「2023年のプロジェクトAの予算」について質問したとします。フラットな検索では、「予算」という単語が含まれる2022年のデータや、プロジェクトBのデータもTop-kに含まれる可能性があります。仮にTop-10を取得し、各チャンクが500トークンだとすると、5,000トークンがコンテキストとしてLLMに送られます。
OpenAIのGPT-4oを使用する場合、入力トークンにも課金が発生します。不要なノイズデータにお金を払っている状態です。
サマリーと詳細を使い分ける階層的アプローチの利点
階層的インデックスは、図書館の検索システムに似ています。いきなり全ページの本文をスキャンするのではなく、まず「目次(サマリー)」を確認し、必要な「章(詳細)」だけを読みます。
LlamaIndexにおける階層化のアプローチは以下の通りです。
- 上位層(Summary Index): ドキュメントごとの要約を保持。データ量は軽量。
- 下位層(Vector Store Index): 各ドキュメントの詳細なテキストチャンクを保持。
クエリはこの階層を順に辿ります。まず上位層で「どのドキュメントを見るべきか」を判断し、対象を絞り込んだ上で、特定のドキュメント内のベクトル検索を行います。
期待できるROI:トークン消費量とレイテンシの改善
このアーキテクチャに変更した場合の試算を行ってみましょう。
- フラット検索: 500トークン × 10チャンク = 5,000トークン
- 階層化検索:
- サマリー検索(全ドキュメントの要約を確認): 200トークン × 5ドキュメント(候補) = 1,000トークン
- 詳細検索(特定された1ドキュメント内): 500トークン × 3チャンク = 1,500トークン
- 合計: 2,500トークン
このモデルケースでは、単純計算で50%のトークン削減となります。さらに、無関係なドキュメント(例えば2022年のデータ)がコンテキストに含まれないため、LLMが誤った情報に基づいて回答するハルシネーション(幻覚)のリスクも低減します。
コストを下げつつ、精度(信頼性)を上げる。これこそが、目指すべきエンジニアリングの姿です。
Step 1: データ構造の分析とインデックス戦略の策定
コードを書く前に、設計図を描きましょう。システム思考において最も重要なのは、「どの粒度でデータを管理するか」という問いです。
ドキュメント単位 vs トピック単位の分割方針
階層化の第一歩は、データの「まとまり」を定義することです。
- ファイルベース: PDFやWordファイル1つを「1つのノード」として扱う。契約書やマニュアルなど、ファイル自体に明確な境界がある場合に有効です。管理が容易で、更新時の再計算コストも抑えられます。
- トピックベース: 複数のファイルにまたがる情報を意味的なまとまりでグルーピングする。例えば「社内規定」というトピックの下に「休暇申請」「経費精算」などのチャンクをぶら下げる形です。
コスト削減と実装の複雑さを天秤にかけると、まずは「ファイルベース」での階層化を推奨します。ファイル単位であれば、メタデータ管理もしやすく、更新検知も容易だからです。
SummaryIndexとVectorStoreIndexの使い分け基準
LlamaIndexには複数のインデックス構造がありますが、階層化では以下の組み合わせが鉄板です。
- 親インデックス:
SummaryIndex(旧 ListIndex) またはVectorStoreIndex- 各ドキュメントの「要約」を保持します。ドキュメント数が数百程度までなら
SummaryIndexで全要約をLLMに読ませて判断させるのが高精度ですが、数千を超える場合は親もVectorStoreIndexにして、要約自体をベクトル検索します。
- 各ドキュメントの「要約」を保持します。ドキュメント数が数百程度までなら
- 子インデックス:
VectorStoreIndex- 各ドキュメントごとの詳細チャンクを保持します。
今回は、ドキュメント数が中規模(数百程度)を想定し、親にVectorStoreIndex(要約のベクトル検索)、子にVectorStoreIndex(詳細のベクトル検索)を配置する構成で解説します。これによりスケーラビリティを確保できます。
Step 2: 階層的インデックスの実装ワークフロー
では、実際にPythonとLlamaIndex(v0.10.x系)を使って構築していきましょう。ここでは、複数のドキュメントがあり、それぞれの「要約」をトップレベルで管理し、必要に応じて「詳細」へドリルダウンする構成を作ります。プロトタイプ思考で、まずは動くものを作って検証することが重要です。
前提として、必要なライブラリをインストールしておいてください。
pip install llama-index llama-index-llms-openai llama-index-embeddings-openai
ドキュメントのロードとノードパーサーの設定
まず、ドキュメントを読み込み、チャンク(Node)に分割します。ここまでは通常のRAGと同じですが、後のステップで使うためにドキュメントごとのタイトルなどをメタデータとして確保しておくことが重要です。
import os
from llama_index.core import SimpleDirectoryReader, Settings, StorageContext, VectorStoreIndex
from llama_index.core.node_parser import SentenceSplitter
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
# APIキーの設定
os.environ["OPENAI_API_KEY"] = "sk-..."
# コスト重視の設定:
# インデックス作成やルーティング判断にはgpt-4o-miniのような安価かつ高速なモデルを推奨
Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
# ドキュメントの読み込み(例:dataフォルダ内のPDFなど)
# ファイル名をメタデータとして保持する設定
documents = SimpleDirectoryReader(
"./data",
filename_as_id=True
).load_data()
# ノードパーサーの設定(詳細用)
splitter = SentenceSplitter(chunk_size=512, chunk_overlap=50)
各ドキュメントの要約(Summary)生成プロセス
ここが階層化の肝です。各ドキュメントに対して、そのドキュメント全体を代表する「要約」を生成し、それを親インデックスに格納します。
from llama_index.core import SummaryIndex
# ドキュメントごとにインデックスを作成し、要約を生成する辞書を用意
document_indices = {}
document_summaries = {}
for doc in documents:
doc_id = doc.doc_id
# 1. ドキュメント単体でVectorStoreIndex(子インデックス)を作成
# これが「詳細検索」の対象になります
doc_index = VectorStoreIndex.from_documents(
[doc],
transformations=[splitter]
)
document_indices[doc_id] = doc_index
# 2. ドキュメントの要約を生成
# SummaryIndexを使って、ドキュメント全体を要約させます
# ※ドキュメントが長い場合は、TreeSummarizeなどが内部で動きます
summary_index = SummaryIndex.from_documents([doc])
summary_query_engine = summary_index.as_query_engine(
response_mode="tree_summarize"
)
summary = str(summary_query_engine.query(
"このドキュメントの主要なトピックと内容を200文字程度で要約してください。"
))
document_summaries[doc_id] = summary
print(f"Processed {doc_id}: {summary[:50]}...")
このプロセスは初回のみ実行し、結果を永続化すべきです。毎回APIを叩いて要約を作るのはコストの無駄だからです。
Step 3: コスト最適化されたクエリエンジンの構築
インデックスができたら、次は「賢い検索エンジン」を作ります。ここで登場するのが RouterQueryEngine です。
RouterQueryEngineによる検索ルートの振り分け
ユーザーの質問を受け取り、「どのドキュメント(ツール)を使うべきか」をLLMに判断させます。これが「司書」の役割を果たします。
from llama_index.core.tools import QueryEngineTool, ToolMetadata
from llama_index.core.query_engine import RouterQueryEngine
from llama_index.core.selectors import LLMSingleSelector
# 各ドキュメントを「ツール」として定義
query_engine_tools = []
for doc_id, doc_index in document_indices.items():
summary = document_summaries[doc_id]
# ツールとして登録
# descriptionに「要約」を入れることで、ルーターが内容を判断できるようにする
query_engine_tools.append(
QueryEngineTool(
query_engine=doc_index.as_query_engine(),
metadata=ToolMetadata(
name=f"tool_{doc_id.replace('.', '_')}", # ツール名は識別子として利用
description=f"このドキュメントの内容: {summary}"
)
)
)
# ルータークエリエンジンの構築
# LLMSingleSelectorは「最も適切な1つのツール」を選ぶ(コスト最小化)
router_query_engine = RouterQueryEngine(
selector=LLMSingleSelector.from_defaults(),
query_engine_tools=query_engine_tools,
verbose=True # デバッグ用に思考プロセスを表示
)
回答生成時のトークン節約テクニック
ここで重要なのは LLMSingleSelector を選んでいる点です。これにより、質問に対して「最も関連性の高い1つのドキュメント」だけが選ばれ、そのインデックス内でのみ検索が実行されます。
もし複数のドキュメントを横断する必要がある場合は LLMMultiSelector を使いますが、コスト削減の観点では、まずSingleで運用可能か検証することをお勧めします。
# 検索実行
response = router_query_engine.query("プロジェクトAの予算について教えて")
print(str(response))
このクエリが実行されると、内部では以下のフローが走ります。
- 選択フェーズ: 登録されたツールの
description(要約)をLLMが見て、「プロジェクトAの予算」に関連しそうなツール(ドキュメント)を1つ選択。 - 検索フェーズ: 選ばれたツールの
QueryEngine(子インデックス)に対してのみベクトル検索を実行。 - 生成フェーズ: 絞り込まれたチャンクだけを使って回答生成。
全ドキュメントを検索するのに比べ、圧倒的に処理対象が少なくなっていることがわかります。
Step 4: 運用監視と継続的なチューニング
システムを構築して本番環境にデプロイした後は、実際の運用においてどれだけコストが削減できたか、そして精度が維持されているかを継続的に監視する仕組みが不可欠です。運用フェーズでの可視化とチューニングが、RAGシステムの真の価値を決定づけます。
トークン使用量のモニタリングとアラート設定
LlamaIndexには、トークン数をカウントするためのコールバックハンドラーが標準で用意されています。開発段階からこれを組み込み、クエリごとの消費量を正確に把握する習慣をつけることを推奨します。
from llama_index.core.callbacks import CallbackManager, TokenCountingHandler
import tiktoken
token_counter = TokenCountingHandler(
tokenizer=tiktoken.encoding_for_model("gpt-4o-mini").encode
)
Settings.callback_manager = CallbackManager([token_counter])
# クエリ実行後にカウントを確認
# ... query execution ...
print("Embedding Tokens: ", token_counter.total_embedding_token_count)
print("LLM Input Tokens: ", token_counter.prompt_llm_token_count)
print("LLM Output Tokens: ", token_counter.completion_llm_token_count)
商用環境への展開後は、LangSmithやArize PhoenixのようなLLM可観測性(Observability)ツールを導入し、ダッシュボード上でコスト推移をリアルタイムに追跡するアプローチが一般的です。
特に、OpenAIのモデルエコシステムは変化が激しく、前述の通りGPT-4oなどの旧モデルからGPT-5.2への移行が進んでいます。API経由での利用状況や、新モデルへ切り替えた際のトークン消費量の変動を正確にモニタリングすることは、予算超過を防ぐ上で極めて重要です。「先週と比べて平均トークン数が急増していないか」を定期的にチェックすることで、非効率な検索パターンの兆候や、予期せぬプロンプトインジェクションのリスクを早期に検知できます。
ドキュメント更新時のインデックス部分更新手順
階層化インデックスを採用するもう一つの大きな利点は、データ更新時の計算コストを大幅に抑制できる点にあります。企業内のドキュメントは日々追加・修正されますが、そのたびに巨大なインデックス全体を再構築するのは非現実的です。
特定のファイルに更新があった場合、そのファイルに対応する「子インデックス」と、上位層にある「要約」部分のみを再生成し、RouterQueryEngine のツールリストを更新するだけで対応が完了します。影響範囲を局所化することで、ベクトルの再計算にかかるAPI費用と処理時間を最小限に抑えつつ、常に最新の情報をRAGシステムに反映させることが可能です。
まとめ:コスト意識はエンジニアの必須スキル
RAGシステムの検索精度を極限まで追求することは技術的に非常に魅力的ですが、ビジネスとして持続可能なコスト構造でなければ、長期的な運用は困難になります。
今回解説した「階層的インデックス」のアーキテクチャを導入することで、以下のような具体的な成果が期待できます。
- APIコストの削減: 検索対象を適切に絞り込み、無駄なコンテキストをプロンプトから排除することで、データ構造によってはトークン消費を大幅に削減できます。
- 検索精度の向上: 上位層のサマリーに基づいた的確なルーティングにより、無関係な情報がノイズとして混入するのを防ぎ、ハルシネーションの発生率を低減させます。
- スケーラビリティの確保: 参照するドキュメント群が大規模化しても、ルーターが階層的にリクエストを振り分けるため、検索速度と応答性能が維持されます。
初期の実装フェーズでは、単一のフラットなインデックスを構築するよりも設計の手間がかかります。しかし、日々のAPIコールで発生する運用コストの差分を考慮すれば、このエンジニアリングへの投資は短期間で十分に回収できるはずです。まずは手元の小規模なデータセットを用いて、この階層化アーキテクチャの挙動とコスト削減効果を検証してみてください。
構造化データと非構造化データが混在するような、より複雑なエンタープライズ環境への適用を検討する際は、最新の技術動向を注視しつつ、自社のユースケースに最適なインデックス戦略を構築していくことが成功の鍵となります。
それでは、Happy Coding!
コメント