StreamlitとRAGで作る 実用的なAIチャットボット(3) ドキュメントローダーとチャンキングの最適化

Streamlit
この記事は約23分で読めます。

こんにちは、JS2IIUです。
連載「StreamlitとRAGで作る:実用的なAIチャットボット開発ガイド」の第3回目の記事になりました。
前回は、プロンプトに手動で情報を埋め込む「最小構成のRAG」を作成しました。しかし、現実の業務では、「社内Wikiの全記事」や「数百ページのPDFマニュアル」など、手作業では到底扱えない量のデータを扱うことになります。

AIにこれらの大量の知識を授けるには、人間が食事をする時と同じように、「データを読み込み(Ingest)」「食べやすい一口サイズに切る(Chunking)」という下ごしらえが必要です。

今回は、RAGの精度を左右すると言っても過言ではない、この「データ取り込みと分割」のプロセスを深掘りします。地味な作業に見えるかもしれませんが、ここを適当に済ませると、どれだけ高価なAIモデルを使っても賢い回答は得られません。

それでは、Pythonを使って非構造化データをAIの燃料に変える技術を学んでいきましょう。今回もよろしくお願いします。

1. はじめに:AIに「本」を読ませるための前処理

RAGシステムにおいて、外部データを取り込む工程は、データサイエンスにおけるETL(Extract, Transform, Load)プロセスによく似ています。

  1. Load (読み込み): PDF、Word、Webページなど、様々な形式のデータをテキストとして取り出す。
  2. Split (分割): 取り出した長いテキストを、AIが扱いやすい小さな単位(チャンク)に分割する。
  3. Embed (ベクトル化) & Store (保存): 分割したテキストを数値化してデータベースに保存する。

今回は、このうちの 1. Load2. Split に焦点を当てます。

なぜそのまま渡してはいけないのでしょうか?
最近のLLMは一度に読める量(コンテキストウィンドウ)が増えていますが、それでも「マニュアル全ページ」を毎回プロンプトに入力するのは、コスト的にも速度的にも非現実的です。また、情報量が多すぎると、AIが重要な部分を見落とす「迷子(Lost in the Middle)」現象も発生しやすくなります。

だからこそ、「必要な時に、必要な部分だけ」を取り出せるように、データを適切に細切れにしておく必要があるのです。

2. Step 1:データを読み込む(Document Loader)

LangChainには、世の中のあらゆるデータソースに対応するための Document Loader というモジュール群が用意されています。これらは共通して load() というメソッドを持ち、実行すると Document オブジェクト(テキスト本文とメタデータのセット)のリストを返します。

まずは必要なライブラリをインストールしましょう。今回はPDFを扱うための pypdf も追加します。

Bash
pip install langchain langchain-community pypdf

基本的なテキストファイルの読み込み

まずはシンプルなテキストファイルを読み込んでみます。

Python
from langchain_community.document_loaders import TextLoader

# サンプル用のテキストファイルを作成(Pythonコード内で作成する場合)
with open("sample.txt", "w", encoding="utf-8") as f:
    f.write("StreamlitはPythonだけでWebアプリが作れるフレームワークです。\n")
    f.write("データサイエンティストに非常に人気があります。\n")
    f.write("RAGアプリのUI構築にも最適です。")

# Loaderの初期化と読み込み
loader = TextLoader("sample.txt", encoding="utf-8")
documents = loader.load()

# 結果の確認
print(f"ドキュメント数: {len(documents)}")
print(f"内容: {documents[0].page_content}")
print(f"メタデータ: {documents[0].metadata}")

実行結果:

Plaintext
ドキュメント数: 1
内容: StreamlitはPythonだけでWebアプリが作れるフレームワークです。
データサイエンティストに非常に人気があります。
RAGアプリのUI構築にも最適です。
メタデータ: {'source': 'sample.txt'}

metadata にはファイルパスなどの情報が自動的に付与されます。これは後々、検索結果に「出典元」を表示する際に役立ちます。

PDFファイルの読み込み

実務で最も要望が多いのがPDFの読み込みです。PyPDFLoader を使います。

Python
from langchain_community.document_loaders import PyPDFLoader

# 読み込みたいPDFのパスを指定
# ※お手持ちの適当なPDFを指定してください
loader = PyPDFLoader("sample_manual.pdf")

# ページごとに分割されて読み込まれます
pages = loader.load()

print(f"総ページ数: {len(pages)}")

# 全ページの内容を表示
for i, page in enumerate(pages):
    print(f"\n--- ページ {i+1} ---")
    print(f"内容: {page.page_content[:200]}...") # 冒頭200文字
print(f"メタデータ: {page.metadata}") # ページ番号などが含まれます

実行結果:

Plaintext
総ページ数: 2

--- ページ 1 ---
内容: StreamlitはPythonだけでWebアプリが作れるフレームワークです 。 データサイエンティストに⾮常に⼈気があります 。 RAGアプリのUI構築にも最適です 。...

--- ページ 2 ---
内容: ここから2ページ⽬です。 Streamlitは、Pythonのみで⼿軽にWebアプリケーションを開発できるフレームワークです。 データ分析の専⾨家の間で特に⽀持されており、RAGアプリケーションのユーザーインターフェース作成にも⾮常に向いています 。...
メタデータ: {'producer': 'macOS バージョン26.1(ビルド25B78) Quartz PDFContext', 'creator': 'PyPDF', 'creationdate': "D:20251220023206Z00'00'", 'moddate': "D:20251220023206Z00'00'", 'source': 'sample_manual.pdf', 'total_pages': 2, 'page': 1, 'page_label': '2'}

PyPDFLoader はPDFをページ単位で Document オブジェクトとして読み込みます。メタデータには {'source': 'sample_manual.pdf', 'page': 0} のようにページ番号が入るため、回答時に「○ページを参照」といった案内が可能になります。

読み込ませたサンプルのPDFファイルはこちらから:sample_manual.pdf

3. Step 2:なぜ分割するのか?「チャンキング」の重要性

ドキュメントの読み込みはできましたが、PDFの1ページあたりの文字数は様々です。また、Markdownファイルなどを読み込んだ場合は、数万文字が一つの塊になっていることもあります。

このままでは、以下の問題が発生します。

  1. 検索精度が落ちる: ベクトル検索(次回解説)は、文章の意味を数値化して比較します。文章が長すぎると、複数のトピックが混ざってしまい、「何について書かれた文章なのか」がぼやけてしまいます。
  2. LLMの容量オーバー: 検索でヒットした文章をプロンプトに埋め込む際、長すぎるとトークン制限を超えてしまいます。

そこで行うのが チャンキング(Chunking) です。
これは、長い文章を「意味の通る最小単位」に切り分ける作業です。

イメージしてください。
あなたが分厚いステーキ(巨大なドキュメント)を食べる時、一度に丸呑みはしませんよね? ナイフで一口サイズに切ってから口に運びます。RAGにおけるチャンキングとは、まさに「AIの口の大きさに合わせて、情報を一口サイズにカットする」作業なのです。

4. Step 3:分割の実装と戦略(Text Splitter)

LangChainには複数のText Splitterがありますが、最も汎用的で推奨されているのが RecursiveCharacterTextSplitter(再帰的文字分割) です。

なぜ “Recursive”(再帰的)なのか?

単純に「100文字ごとに切る」とどうなるでしょうか?
もし、ちょうど大事なキーワードや文章の途中でブツッと切れてしまったら、意味が分からなくなります。

RecursiveCharacterTextSplitter は、以下の順序で区切り文字を探し、文章がなるべく自然な形で収まるように努力します。

  1. 段落の区切り(\n\n
  2. 改行(\n
  3. 空白(スペース)
  4. 文字そのもの

「まずは段落で区切ってみる。それでも指定サイズより大きければ、次は改行で区切ってみる…」という処理を再帰的に行うため、文脈(コンテキスト)が維持されやすいのです。

重要な2つのパラメータ

チャンキングには2つの重要なパラメータがあります。

  • chunk_size: 1つの塊の最大サイズ(文字数やトークン数)。
  • chunk_overlap: 前後のチャンク同士をどれくらい重複させるか。

オーバーラップ(重複)の重要性:
文章の区切り目が、文脈上重要な接続部分である可能性があります。オーバーラップ(chunk_overlap)は、各チャンクの末尾と次のチャンクの先頭が一部重複するように分割する手法です。これにより、以下の技術的メリットがあります:

  • 文脈の連続性保持
    • 文章の意味がチャンクの境界で途切れるのを防ぎ、前後の文脈をまたいだ情報検索が可能になります。特に、段落や会話のつながり、因果関係などが維持されやすくなります。
  • 検索精度の向上
    • ユーザーの質問がチャンクの境界付近に関連する場合でも、オーバーラップによって関連情報が漏れにくくなり、より適切なコンテキストをLLMに渡せます。
  • ベクトル検索の安定化
    • チャンクごとに埋め込み(ベクトル化)する際、オーバーラップがあることで、意味的に近いチャンク同士のベクトルが滑らかにつながり、検索結果の品質が向上します。
  • 分割アルゴリズムの柔軟性
    • 実装例(LangChainなど)では、chunk_sizeとchunk_overlapをパラメータで調整でき、用途やデータの性質に合わせて最適な分割方法を選択できます。

例:chunk_size=100, chunk_overlap=20の場合
1つ目のチャンクは1〜100文字、2つ目は81〜180文字、3つ目は161〜260文字…というように、各チャンクが20文字分重複します。
これにより、100文字目付近の情報が2つのチャンクにまたがって保持され、検索や生成時に文脈が途切れにくくなります。

5. 実践コード:分割結果を可視化して比較する

では、実際に分割してみましょう。ここでは効果を分かりやすくするため、短めの chunk_size で実験します。

Python
from langchain_text_splitters import RecursiveCharacterTextSplitter

long_text = """
Streamlitは、機械学習やデータサイエンスのためのWebアプリケーションフレームワークです。
Pythonスクリプトだけで、インタラクティブなウィジェットを備えた美しいUIを構築できます。

RAG(Retrieval-Augmentation-Generation)は、LLMに外部知識を与える技術です。
これにより、AIは学習していない最新情報や社内ドキュメントに基づいて回答できるようになります。

チャンキングはRAGにおいて非常に重要なプロセスです。
適切なサイズに分割することで、検索精度を向上させることができます。
"""

# chunk_overlap の値を別途変数に保存します。
CHUNCK_OVERLAP_SIZE = 20

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=100,
    chunk_overlap=CHUNCK_OVERLAP_SIZE, # ここで変数を使用
    length_function=len,
    separators=["\n\n", "\n", " ", ""]
)

chunks = text_splitter.create_documents([long_text])

print(f"元の文字数: {len(long_text)}")
print(f"分割後のチャンク数: {len(chunks)}")
print("=" * 40)

# text_splitter.chunk_overlap ではなく、保存した変数を使用します。
overlap_size = CHUNCK_OVERLAP_SIZE

for i, chunk in enumerate(chunks):
    content = chunk.page_content

    print(f"Chunk {i+1} ({len(content)}文字)")
    print(f"本文: 「{content}」")

    if i > 0:
        prev_content = chunks[i - 1].page_content
        overlap_prev = prev_content[-overlap_size:]
        overlap_curr = content[:overlap_size]

        print("▼ オーバーラップ部分")
        print(f"前チャンク末尾 : 「{overlap_prev}」")
        print(f"現チャンク先頭 : 「{overlap_curr}」")

    print("-" * 40)

実行結果のイメージ

Plaintext
元の文字数: 269
分割後のチャンク数: 4
========================================
Chunk 1 (97文字)
本文: 「Streamlitは、機械学習やデータサイエンスのためのWebアプリケーションフレームワークです。
Pythonスクリプトだけで、インタラクティブなウィジェットを備えた美しいUIを構築できます。」
----------------------------------------
Chunk 2 (57文字)
本文: 「RAG(Retrieval-Augmentation-Generation)は、LLMに外部知識を与える技術です。」
▼ オーバーラップ部分
前チャンク末尾 : 「ェットを備えた美しいUIを構築できます。」
現チャンク先頭 : 「RAG(Retrieval-Augmen」
----------------------------------------
Chunk 3 (47文字)
本文: 「これにより、AIは学習していない最新情報や社内ドキュメントに基づいて回答できるようになります。」
▼ オーバーラップ部分
前チャンク末尾 : 「)は、LLMに外部知識を与える技術です。」
現チャンク先頭 : 「これにより、AIは学習していない最新情報」
----------------------------------------
Chunk 4 (61文字)
本文: 「チャンキングはRAGにおいて非常に重要なプロセスです。
適切なサイズに分割することで、検索精度を向上させることができます。」
▼ オーバーラップ部分
前チャンク末尾 : 「ントに基づいて回答できるようになります。」
現チャンク先頭 : 「チャンキングはRAGにおいて非常に重要な」
----------------------------------------

Chunk 2の冒頭を見てください。「UIを構築できます。」という部分がChunk 1の末尾と重複しています。これが chunk_overlap の効果です。これにより、もし「UI構築」に関する質問が来たとき、Chunk 1とChunk 2の境界部分に情報があっても、どちらかのチャンクが文脈を保持して検索に引っかかる可能性が高まります。

最適な設定値の目安:
一般的な日本語ドキュメントの場合、以下のような設定から始めることが多いです。

  • chunk_size: 500 〜 1000文字
  • chunk_overlap: 50 〜 100文字

※ 使用するEmbeddingモデルやLLMのコンテキストウィンドウによって最適値は変わります。

6. Tips:Streamlitでのファイル処理における注意点

本連載の主役であるStreamlitについても触れておきます。
Streamlitの st.file_uploader でファイルをアップロードすると、データはメモリ上のバイナリとして保持されます(BytesIOライクなオブジェクト)。

一方、LangChainの PyPDFLoader など多くのLoaderは、「ファイルパス(ディスク上のファイル)」を引数に取る仕様になっています。

そのため、StreamlitでアップロードされたファイルをLangChainで読み込むには、「一度一時ファイルとして保存し、そのパスをLoaderに渡す」という工夫が必要です。

Python
import streamlit as st
import tempfile
import os
from langchain_community.document_loaders import PyPDFLoader

st.title("PDFアップロード & 解析テスト")

uploaded_file = st.file_uploader("PDFファイルをアップロード", type="pdf")

if uploaded_file is not None:
    # 一時ファイルを作成して保存
    with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file:
        tmp_file.write(uploaded_file.getvalue())
        tmp_file_path = tmp_file.name

    try:
        # 保存したパスを使ってLoaderを起動
        loader = PyPDFLoader(tmp_file_path)
        pages = loader.load()

        st.success(f"{len(pages)} ページのPDFを読み込みました!")

        # 内容のプレビュー
        st.write("--- 1ページ目の内容 ---")
        st.text(pages[0].page_content)

    finally:
        # 処理が終わったら一時ファイルを削除(お掃除)
        os.remove(tmp_file_path)

このパターン(tempfile の利用)は、Streamlitでファイル処理を行う際の定石ですので、ぜひ覚えておいてください。

7. まとめと次回予告

今回は、RAGアプリ構築の土台となる「データ取り込み(Load)」と「チャンキング(Split)」について解説しました。

  • Document Loader: 多様なデータを統一フォーマットに変換する入り口。
  • Chunking: 巨大なデータをAIが理解しやすい一口サイズに分割する処理。
  • RecursiveCharacterTextSplitter: 文脈を維持しながら分割する賢いツール。
  • Overlap: 情報の分断を防ぐための保険。

これで、手元のPDFファイルを「AIが食べられる状態」に加工する準備が整いました。
しかし、分割されたテキストデータのままでは、まだ検索には使えません。AIは日本語の文章を直接検索するよりも、「意味のベクトル(数値の羅列)」に変換して計算する方が得意だからです。

次回、第4回「知識を埋め込む:エンベディングとローカルVector Storeの構築」では、今回作ったテキストチャンクをAIの言葉(ベクトル)に翻訳し、高速に検索できるデータベース(Vector Store)に保存する方法を学びます。FAISSやChromaDBといった、RAG開発で頻出のキーワードが登場します。

データが「知識」へと変わる瞬間を、次回もぜひ一緒に体験しましょう。最後まで読んでいただきありがとうございました。

連載記事リンク

コメント

タイトルとURLをコピーしました