RAG (Retrieval Augmented Generation) システムを構築している多くのエンジニアが直面する課題、それは「LLMの応答品質が安定しない」「期待通りの情報が取得できない」というものです。これらの問題の根本原因の多くは、実は不適切なチャンキングにあります。関連性の低い情報が混入したり、重要なコンテキストが失われたりすることで、LLMはハルシネーションを起こしたり、不正確な応答を生成してしまいます。
この記事では、RAGの性能向上とコスト削減に直結するチャンキングの最適化戦略に焦点を当てます。LangChainとLlamaIndexの最新バージョンにおける多様なチャンキング戦略、埋め込みモデル選定、そしてRetrieval評価の実践的なベストプラクティスを、具体的なコード例を交えて解説します。
RAGにおけるチャンキングの重要性とその基本概念
このセクションでは、なぜRAGシステムにおいてチャンキングが極めて重要なのか、その基本概念と役割について解説します。
RAGシステムは、大きく分けて「検索(Retrieval)」と「生成(Generation)」の二つのフェーズで構成されます。チャンキングは、このうち「検索」フェーズの基盤を形成する、テキストデータの前処理プロセスです。大規模なドキュメントを、LLMが処理しやすい小さな単位(チャンク)に分割します。このチャンクの質が、埋め込みの精度、ひいては検索関連性に直結するため、RAGの応答品質を左右する決定的な要素となります。
具体的には、チャンキングは以下の課題を解決します。
- LLMのコンテキストウィンドウ制限: LLMは一度に処理できるトークン数に限りがあります。チャンキングにより、ドキュメント全体を一度に処理できない場合でも、関連性の高い部分だけをLLMに渡せるようになります。
- 検索効率の向上: 小さなチャンクは、クエリとのセマンティックな類似度をより正確に計算しやすくなります。これにより、関連性の高い情報が効率的に取得されます。
- コスト削減: LLMへの入力トークン数が減ることで、API利用料などの運用コストを削減できます。
LangChainとLlamaIndexにおけるチャンキング戦略
このセクションでは、主要なRAGフレームワークであるLangChainとLlamaIndexが提供する多様なチャンキング戦略と、それぞれの特徴、および推奨されるユースケースについて解説します。
LangChainのチャンキング戦略
LangChainでは、TextSplitterという抽象クラスを継承した様々なチャンカーが提供されています。特にRecursiveCharacterTextSplitterは、一般的なテキストに対して最も推奨されるアプローチです。
-
RecursiveCharacterTextSplitter:-
特徴: 指定された区切り文字のリスト(例:
["\n\n", "\n", " ", ""])を順に試しながら、テキストを再帰的に分割します。これにより、ドキュメントの自然な構造(段落、文、単語)を最大限に尊重し、セマンティックな断片化を防ぎます。 -
パラメータ:
chunk_size(チャンクの最大文字数またはトークン数)、chunk_overlap(チャンク間のオーバーラップ文字数またはトークン数)、separators(区切り文字のリスト)。 - 推奨ユースケース: ほとんどの汎用テキストデータ。
-
特徴: 指定された区切り文字のリスト(例:
-
CharacterTextSplitter:- 特徴: 単一の区切り文字でテキストを分割します。シンプルですが、セマンティックな境界を無視しやすいです。
- 推奨ユースケース: 非常にシンプルな構造のテキスト、デバッグ時。
-
TokenTextSplitter:-
特徴:
tiktokenなどのトークナイザーを使用して、指定されたトークン数に基づいてテキストを分割します。LLMのコンテキストウィンドウに直接合わせる場合に有効です。 - 推奨ユースケース: トークン数に厳密な制限がある場合、LLMへの入力サイズを直接制御したい場合。
-
特徴:
-
構造認識型スプリッター:
HTMLSectionSplitter、CodeTextSplitter、RecursiveJsonSplittingなど。- 特徴: HTMLタグ、コードの構文、JSON構造など、特定のドキュメント構造を解析して意味のある単位で分割します。
- 推奨ユースケース: ウェブページ、ソースコード、構造化されたJSONデータなど。
-
SemanticSplitting(実験的):- 特徴: 埋め込みの類似度に基づいてテキストを分割します。意味の連続性が高い部分をまとめてチャンク化することで、よりセマンティックなチャンクを作成します。
- 推奨ユースケース: 高度なセマンティック理解が必要な場合、従来のチャンキングで精度が出ない場合。
LangChainでのRecursiveCharacterTextSplitterの使用例
langchain-text-splittersライブラリを使用します。
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document # Documentオブジェクトのインポート
# テキストデータ
text = """
Madame Speaker, Vice President Biden, members of Congress, distinguished guests, and fellow Americans:
Our Constitution declares that from time to time, the president shall give to Congress information about the state of our union.
For 220 years, our leaders have fulfilled this duty. They have done so during periods of prosperity and tranquility.
And they have done so in the midst of war and depression; in times of challenge and in times of triumph.
It's tempting to look back on these moments and assume that our progress was inevitable, that America was always destined to succeed.
But when the Union was turned back at Bull Run and the Allies first landed at Omaha Beach, victory was very much in doubt.
When the market crashed on Black Tuesday and civil rights marchers were beaten on Bloody Sunday, the future was anything but certain.
"""
# RecursiveCharacterTextSplitterの設定
# chunk_size: 各チャンクの最大文字数 (length_function=lenの場合)
# chunk_overlap: チャンク間のオーバーラップ文字数
# separators: 分割を試みる区切り文字のリスト(優先順位順)
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n\n", "\n", " ", ""],
length_function=len, # 文字数で長さを計算
is_separator_regex=False,
)
# テキストをチャンクに分割
# create_documentsはリストを受け取るため、[text]とする
chunks = text_splitter.create_documents([text])
# 分割されたチャンクの表示
for i, chunk in enumerate(chunks):
print(f"Chunk {i} length: {len(chunk.page_content)} chars")
print(chunk.page_content)
print("-" * 50)
LlamaIndexのチャンキング戦略
LlamaIndexでは、ドキュメントをNodeオブジェクトに分割するために「Node Parser」という用語が使用されます。各ノードは親ドキュメントの個別のチャンクを表し、親ドキュメントのすべての属性を継承します。
-
SentenceSplitter:- 特徴: 最も基本的で一般的に使用される戦略です。文の境界でテキストを分割し、指定された最大サイズ以下のチャンクにグループ化します。デフォルトでトークンベースの分割を行います。
-
パラメータ:
chunk_size(チャンクの最大トークン数)、chunk_overlap(チャンク間のオーバーラップトークン数)。 - 推奨ユースケース: ほとんどの汎用テキストデータ、特に文レベルでの意味の保持が重要な場合。
-
TokenTextSplitter:- 特徴: LangChainと同様に、トークン数に基づいてテキストを分割します。
- 推奨ユースケース: トークン数に厳密な制限がある場合。
-
構造認識型Node Parser:
HTMLNodeParser、JSONNodeParser、MarkdownNodeParser、CodeSplitterなど。- 特徴: 各形式の構造を解析し、意味のあるノードを生成します。
- 推奨ユースケース: HTMLドキュメント、JSONファイル、Markdownファイル、ソースコードなど。
-
SentenceWindowNodeParser:- 特徴: 検索時には小さなチャンク(ウィンドウ)を使用し、生成時にはそのウィンドウの周囲にあるより大きな文脈(フルセンテンス)をLLMに提供します。これにより、検索精度とコンテキスト保持のバランスを取ります。
- 推奨ユースケース: 検索精度と生成時のコンテキストの両方が重要な場合。
-
SemanticSplitter:- 特徴: LangChainと同様に、埋め込みモデルを使用してテキストの意味的な類似度に基づいて分割します。
- 推奨ユースケース: 高度なセマンティック理解が必要な場合。
LlamaIndexでのSentenceSplitterの使用例
llama-index-coreライブラリを使用します。
from llama_index.core import Document
from llama_index.core.node_parser import SentenceSplitter
# テキストデータ
text = """
Madame Speaker, Vice President Biden, members of Congress, distinguished guests, and fellow Americans:
Our Constitution declares that from time to time, the president shall give to Congress information about the state of our union.
For 220 years, our leaders have fulfilled this duty. They have done so during periods of prosperity and tranquility.
And they have done so in the midst of war and depression; in times of challenge and in times of triumph.
It's tempting to look back on these moments and assume that our progress was inevitable, that America was always destined to succeed.
But when the Union was turned back at Bull Run and the Allies first landed at Omaha Beach, victory was very much in doubt.
When the market crashed on Black Tuesday and civil rights marchers were beaten on Bloody Sunday, the future was anything but certain.
"""
# LlamaIndexのDocumentオブジェクトを作成
document = Document(text=text)
# SentenceSplitterの設定
# chunk_size: 各チャンクの最大トークン数 (LlamaIndexのデフォルトは1024)
# chunk_overlap: チャンク間のオーバーラップトークン数 (LlamaIndexのデフォルトは20)
# LlamaIndexのSentenceSplitterはデフォルトでトークンベースの分割を行う
splitter = SentenceSplitter(chunk_size=1024, chunk_overlap=20)
# ドキュメントをノード(チャンク)に分割
nodes = splitter.get_nodes_from_documents([document])
# 分割されたノードの表示
for i, node in enumerate(nodes):
print(f"Node {i} length: {len(node.text)} chars")
print(node.text)
print("-" * 50)
LangChainとLlamaIndexの連携におけるチャンキング
LlamaIndexはRAGプロセスのデータ取り込み、インデックス作成、検索部分に優れており、LangChainはLLMワークフロー全体のオーケストレーションに強みがあります。LlamaIndexで作成したインデックスとクエリエンジンをLangChainのRetrieverとして利用するのが一般的な連携パターンです。この場合、チャンキングはLlamaIndex側で行われることになります。
LlamaIndex RetrieverをLangChainのRetrievalQAチェーンに統合する例
import os
from langchain.chains import RetrievalQA
from langchain_openai import ChatOpenAI
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Document as LlamaDocument
from llama_index.core.retrievers import QueryFusionRetriever # LlamaIndexのRetrieverを直接インポート
# 環境変数にOpenAI APIキーを設定してください
# os.environ["OPENAI_API_KEY"] = "YOUR_OPENAI_API_KEY"
# 1. LlamaIndexのインデックスを構築
# 実際にはファイルから読み込む
# documents = SimpleDirectoryReader("data").load_data()
# index = VectorStoreIndex.from_documents(documents)
# ここでは簡略化のため、ダミーのLlamaIndexインデックスオブジェクトを作成
# 実際のLlamaIndexのDocumentオブジェクトを使用
dummy_documents = [
LlamaDocument(text="LlamaIndex excels at data ingestion, indexing, and advanced retrieval strategies."),
LlamaDocument(text="LangChain provides a comprehensive framework for building LLM applications, including agents and chains."),
LlamaDocument(text="Vector databases are crucial for efficient similarity search in RAG systems."),
LlamaDocument(text="Chunking strategies significantly impact the relevance of retrieved documents.")
]
index = VectorStoreIndex.from_documents(dummy_documents)
# 2. LangChainのLLMオブジェクトを初期化
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
# 3. LlamaIndexからRetrieverオブジェクトを取得
# LlamaIndexのas_retriever()はLangChainのBaseRetrieverインターフェースと互換性がある
llama_retriever = index.as_retriever(similarity_top_k=2) # 取得するドキュメント数を指定
# 4. LangChainのRetrievalQAチェーンをインスタンス化
# chain_type="stuff" は、取得したすべてのドキュメントをLLMのプロンプトに詰め込む最もシンプルな方法
rag_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=llama_retriever,
return_source_documents=True # 取得したソースドキュメントも返すように設定
)
# 5. 統合されたチェーンを使用してクエリを実行
query = "What are the main benefits of using vector databases for RAG?"
response = rag_chain.invoke({"query": query}) # .run()は非推奨になり、.invoke()が推奨されます
print("Query:", query)
print("LLM Response:", response['result'])
print("\nSource Documents:")
for doc in response['source_documents']:
print(f"- {doc.page_content}")
# 注意: このコードを実行するには、OpenAI APIキーが必要です。
# `pip install langchain-openai llama-index` で必要なライブラリをインストールしてください。
チャンキング最適化におけるよくある落とし穴と回避策
このセクションでは、RAGのチャンキングでエンジニアが陥りやすい一般的な問題点と、それらを回避するための実践的な解決策を解説します。
1. 問題: チャンクサイズがLLMのコンテキストウィンドウを超過する
説明: LLMには最大トークン制限があり、チャンクがこの制限を超えると、情報が切り捨てられたり、モデルが適切に処理できなかったりします。特に、埋め込みモデルのトークン制限とLLMのトークン制限は異なる場合があるため注意が必要です。
回避策:
- 使用するLLMと埋め込みモデルのコンテキストウィンドウに合わせてチャンクサイズを調整します。
- トークンベースのチャンキングを使用し、
tiktokenのようなライブラリで正確なトークン数をカウントします。LangChainのTokenTextSplitterやLlamaIndexのSentenceSplitterは内部でトークンカウントを考慮します。 - より大きなコンテキストウィンドウを持つLLMの使用を検討します(例: GPT-4 Turbo, Claude 3)。
- 「Parent Document Retriever」のような高度なRAG技術を導入し、小さな子ドキュメントで正確な埋め込みを作成し、親ドキュメントから追加のコンテキストを取得して生成時に利用します。
2. 問題: チャンクが意味のある境界で分割されず、コンテキストが失われる(セマンティックな断片化)
説明: 固定サイズのチャンキングなど、構造を考慮しないチャンキングは、文や段落の途中でチャンクが分割され、重要なコンテキストが失われる可能性があります。これにより、検索時に関連性の低いチャンクが取得されたり、取得されたチャンクだけでは意味が通じなくなったりします。
回避策:
-
RecursiveCharacterTextSplitter(LangChain) やSentenceSplitter(LlamaIndex) のように、自然な言語の境界(改行、文の終わり、段落の区切りなど)を尊重するチャンキング戦略を使用します。 - チャンク間のオーバーラップを適切に設定し、境界での情報損失を減らします。
- HTMLやコードなど、特定の構造を持つドキュメントには、
HTMLSectionSplitterやCodeTextSplitterのような構造認識型のチャンカーを使用します。 - LlamaIndexの
SemanticSplitterのようなセマンティックチャンキングを検討し、埋め込みの類似度に基づいてチャンクを分割します。
3. 問題: チャンクが大きすぎて関連性の低い情報が混入し、検索精度が低下する
説明: チャンクが大きすぎると、複数のトピックが混在し、クエリとの類似度スコアが希薄になり、関連性の低いチャンクが取得される可能性が高まります。これは「ノイズの多いチャンク」と呼ばれ、LLMのハルシネーションの原因にもなります。
回避策:
- ユースケースに合わせてチャンクサイズを最適化します。事実に基づいた検索には小さいチャンク(128〜256トークン)、コンテキストが重要なタスクには大きいチャンク(512〜1024トークン)が適しています。
- 異なるチャンキング戦略をテストし、特定のドキュメントとクエリで最適なアプローチを比較します。
- ハイブリッドアプローチを検討し、異なるドキュメントタイプに対して異なる戦略を使用します。
- 階層的な親子チャンキング(Hierarchical parent-child chunking)を導入し、検索時には小さなチャンクで高い精度を確保し、生成時にはより大きな親チャンクで豊富なコンテキストを提供することで、このトレードオフを解決します。
4. 問題: 非構造化データ(PDFなど)からのテキスト抽出が不十分で、チャンキングがうまく機能しない
説明: PDFなどの非構造化データからテキストを抽出する際に、レイアウトの崩れ、図表内のテキスト、スキャン画像からのOCR精度不足などにより、意味のあるテキストが抽出されないことがあります。これにより、チャンキングが意図しない場所で分割されたり、重要な情報が欠落したりします。
回避策:
- LlamaParseのような専用のPDF解析ツールを使用し、構造化されたテキストやMarkdown形式で抽出します。
- OCRの品質が高いツールやサービスを利用します。
- 前処理ステップで、不要なヘッダー/フッター、ページ番号、図表キャプションなどを除去し、クリーンなテキストをチャンキングに渡します。
- 必要に応じて、手動でのデータクレンジングや正規表現を用いたパターンマッチングでテキストを整形します。
チャンキング戦略の設計上のトレードオフとベストプラクティス
このセクションでは、RAGシステムにおけるチャンキング戦略を設計する際の主要なトレードオフと、効果的な実装のためのベストプラクティスを解説します。
チャンキングのトレードオフ
チャンキング戦略の選択には、常にいくつかのトレードオフが伴います。
-
チャンクサイズ:
- 小さいチャンク: 検索精度が向上するが、周囲のコンテキストが失われるリスクがある。LLMへの入力トークン数を抑えられるため、コスト面で有利。
- 大きいチャンク: より多くのコンテキストを保持できるが、関連性が希薄になり、埋め込みの精度が低下する可能性がある。LLMのトークン制限を超えるリスクも高まる。
-
オーバーラップ:
- 多いオーバーラップ: チャンク境界での情報損失を減らし、コンテキストの連続性を高めるが、ストレージコストと処理時間が増加する。
- 少ないオーバーラップ: ストレージと処理コストを削減するが、コンテキストの断片化のリスクがある。
-
チャンキング戦略の複雑さ:
- シンプルな方法(固定サイズ、文字ベース): 高速で安価だが、セマンティックな境界を無視し、構造を破壊する可能性がある。
- 構造認識型(再帰的、文ベース、ページレベル): 適度な複雑さを追加するが、自然な境界を尊重し、コンテキストの保持を改善する。
- セマンティックベース(埋め込み生成): 検索精度を最大9%向上させる可能性があるが、チャンキングプロセス中に埋め込みを生成する必要があるため、計算コストが高く、遅くなる。
- LLMベース: LLMがドキュメント構造を分析するため、非常に明確でコンテキスト豊かなチャンクを作成できるが、計算コストと費用が最も高い。
チャンキングのベストプラクティス
これらのトレードオフを踏まえ、RAGシステムでチャンキングを最適化するためのベストプラクティスを以下に示します。
-
シンプルに始める: まずは
SentenceSplitter(LlamaIndex) やRecursiveCharacterTextSplitter(LangChain) のような基本的な戦略から始め、パフォーマンスを評価します。 - 複数の戦略をテストする: 特定のドキュメントとクエリを使用して、異なるチャンキングアプローチを比較します。LangSmithやLlamaIndexの評価モジュールを活用し、定量的・定性的に評価することが重要です。
-
パラメータを調整する: コンテンツの特性に基づいて、
chunk_sizeとchunk_overlapを調整します。技術文書や密度の高いテキストには小さいチャンク、物語のようなコンテンツには大きいチャンクが適している場合があります。一般的な推奨値はchunk_size512〜1024トークン、chunk_overlap10%〜20%です。 - ハイブリッドアプローチを検討する: コレクション内の異なるドキュメントタイプに対して、異なるチャンキング戦略を使用します。例えば、FAQには文ベース、長文の技術ドキュメントには再帰的チャンキングなど。
- 検索品質を監視する: 関連性やコンテキストの保持などのメトリクスを追跡し、チャンキング戦略の変更が効果的かどうかを判断します。
- チャンクサイズとセマンティクスのバランス: 意味を維持し、管理しやすいチャンクサイズを両立させる最適なチャンキング戦略を見つけます。
- ドキュメント構造とメタデータを保持する: メタデータ(ソース属性、構造階層、時間マーカーなど)をチャンクに含めることで、より洗練された検索戦略(事前フィルタリング、事後フィルタリング、ハイブリッド検索)を実装できます。
- 継続的なテストと評価: チャンクサイズを最適化し、システムパフォーマンスを検証するために、継続的なテストフレームワークを実装します。
- クリーンな入力データ: OCRや前処理の品質はチャンキング戦略の選択と切り離せません。クリーンで構造化された入力テキストが、効果的なチャンキングの前提となります。LlamaParseのようなツールを活用することで、PDFなどの非構造化データからのテキスト抽出精度を大幅に向上できます。
-
階層的な親子チャンキング: 2025-2026年で最も広く採用されているプロダクションパターンであり、小さなチャンクで高い検索精度を、大きな親チャンクで豊富な生成コンテキストを提供することで、精度とコンテキストのトレードオフを解決します。LlamaIndexの
SentenceWindowNodeParserなどがこのアプローチをサポートします。
まとめ
本記事では、RAGシステムにおけるチャンキングの重要性から、LangChainとLlamaIndexの最新バージョンにおける多様なチャンキング戦略、具体的な実装例、そしてよくある落とし穴とその回避策、さらに設計上のトレードオフとベストプラクティスについて解説しました。
RAGのパフォーマンスを最大化するためには、以下のポイントが鍵となります。
- チャンキングの目的理解: LLMのコンテキストウィンドウ、検索効率、コスト削減という3つの側面からチャンキングの役割を理解する。
-
適切な戦略の選択: ドキュメントの種類、クエリの性質、システム要件に応じて、
RecursiveCharacterTextSplitterやSentenceSplitter、さらには構造認識型やセマンティックチャンキングなど、多様な戦略を使い分ける。 -
パラメータの最適化:
chunk_sizeとchunk_overlapをコンテンツの特性に合わせて調整する。 - 高度な技術の検討: 親子チャンキングやセマンティックチャンキングなど、より洗練されたアプローチを導入することで、精度とコンテキスト保持のトレードオフを解決する。
- 継続的な評価と改善: LangSmithなどのツールを活用し、チャンキング戦略がRAGシステムの全体的なパフォーマンスに与える影響を常に測定し、改善サイクルを回す。
RAGシステムの成功は、適切なチャンキング戦略にかかっていると言っても過言ではありません。本記事で紹介した内容が、皆さんのRAGシステム開発の一助となれば幸いです。
さらに深く学びたい場合は、LangChainおよびLlamaIndexの公式ドキュメントを参照し、それぞれのフレームワークが提供する最新の機能や推奨事項を確認することをおすすめします。特に、RAG評価に関するセクションや、高度なRetrieverの実装例は非常に参考になります。