Langchain の load_and_split()では最終的に何が生成されるのか？（PyMuPDFLoaderで実装）

この記事はラクスパートナーズ Advent Calendar 2024の17日目の記事です。

## 本記事の目的
初めての現場にアサインされてすぐ、langchainの`load_and_split()`に触れる機会があったのですが、langchain初心者&ドキュメントを読みことにあまり慣れていなかった私は、公式ドキュメントを読むだけで大変でした。。。
なので、langchain初心者向けに記事を書いてみました。

当時私が知りたかったのは、
①デフォルトでは何文字に分割される？そもそも`chunk_size`で指定する数は文字数なのか？
②違うページのテキストが同じチャンクになることはあるのか？

ということだったので、このあたりに焦点を当ててソースコードを読んで理解した内容を書こうと思います。

## 実行環境
- Google Colaboratory
- Python: 3.10.12
- PyMuPDF: 1.24.14
- langchain_community: 0.3.8

## 注意事項
- 本記事はv0.3の内容を元に作成しています。
- langchainはバージョンアップなどが頻繁に行われるライブラリです。
記載の内容は本記事の作成時点（2024年11月30日）での内容という点をご了承ください。
- langchainで実装されている`PDFloader`には様々な種類がありますが、今回は`PyMuPDFLoader`を扱っています。

## `load_and_split()`とは何か？
今回実装している`PyMuPDFLoader`は、Pythonの`PyMuPDF`ライブラリを使ってPDFファイルをロードし、その内容を分割する関数です。分割後に生成された塊をチャンクと呼びます。
私は`load_and_split()`で分割したテキストをOPENAIAPIで処理する作業をしていたので、OPENAIのMAX_TOKENSを超えないためにはどう分割するのがいいのか？ということに興味がありました。

### サンプルコード
以下のlangchain公式サイトを参考に実装してみます。
https://python.langchain.com/docs/integrations/document_loaders/pymupdf/#initialization

今回もサンプルファイルとしてこちらの論文を使用させていただきました。
「電車混雑予測 ～混雑の可視化が社会にもたらすインパクト～」
https://data.navitime.co.jp/pdf/monograph_20170610_2.pdf

```python: load_and_split()の実装
from langchain_community.document_loaders import PyMuPDFLoader
loader = PyMuPDFLoader("/content/sample.pdf")
docs = loader.load_and_split()
print(f'{len(docs)}個のDocumentオブジェクトが生成')
```
【出力結果】
```python: 
13個のDocumentオブジェクトが生成
```

1つ目のオブジェクトの中身を確認してみます。
```python: 
# Documentオブジェクトの中身を確認
docs[0]
```
【出力結果】
※見やすいように改行しています
```python: 
Document(
    metadata={'source': '/content/sample.pdf', 'file_path': '/content/sample.pdf', 'page': 0, 'total_pages': 13, 'format': 'PDF 1.5', 'title': '【論文】', 'author': 'Kazuo KONAGAI', 'subject': '', 'keywords': '', 'creator': 'Microsoft® Word 2016', 'producer': 'Microsoft® Word 2016', 'creationDate': "D:20170428114410+09'00'", 'modDate': "D:20170428114410+09'00'", 'trapped': ''}, 
    page_content='1 \n \n電車混雑予測 \n～混雑の可視化が社会にもたらすインパクト～ \n \n \n \n岡野 宙輝1・太田 恒平2・廣田 正之3 \n \n1 株式会社ナビタイムジャパン 鉄道予測プロジェクト（〒107-0062 東京都港区南青山3-8-38） \nE-mail:hiroki-okano@navitime.co.jp \n2 株式会社ナビタイムジャパン メディア事業部 （〒107-0062 東京都港区南青山3-8-38） \nE-mail:kohei-ota @navitime.co.jp \n3 株式会社ナビタイムジャパン 鉄道予測プロジェクト （〒107-0062 東京都港区南青山3-8-38） \nE-mail:masayuki-hirota @navitime.co.jp \n \n鉄道の混雑は都市交通インフラに残された大きな課題であり，適切な情報提供による混雑分\n散や着席列車の利用促進が期待される．しかしこれまで鉄道の混雑率に関する情報は，一部の\n路線を除き，朝ラッシュピーク時の再混雑区間の平均値という断片的な情報しか公開されてこ\nなかった． \nそこで筆者らは，首都圏を対象に始発から終電まで各列車の停車駅ごとの混雑度を推定する\n「電車混雑予測」を開発し，朝ラッシュだけでなく，帰宅ラッシュ，ピークサイド時の混雑状\n況も明らかにした． \nさらに，ユーザが経路選択の意思決定をするタイミングである乗換検索アプリ上にて，各経\n路の混雑情報を提供することで混雑回避行動を促すとともに，その経路選択データを記録し情\n報提供の効果測定を可能とした． \n本研究では，首都圏の全時間帯の電車混雑の詳細な実態，乗換検索サービスにおける混雑の\n情報提供が及ぼす経路選択への影響などについて報告する． \n \n     Key Words: railway, congestion, simulation, route choice model \n \n1. はじめに \n \n鉄道の混雑は都市交通インフラに残された大きな課題\nである．2016 年には，「満員電車をゼロへ」を公約 1)に\n掲げた小池百合子が東京都知事に当選し「快適通勤ムー\nブメント」2)を開始していることからも，混雑対策への\n社会的な要請が高いことが伺える． \n混雑対策のためには，鉄道利用者に対する混雑状況の\n「見える化」を通じた混雑分散やサービス設計の重要性\nが国交省答申 3)においても指摘されている．鉄道事業者\nは，独自のアプリ 4)やWebサイト上で混雑率情報を公開\nし混雑の平準化に取り組んでいる．しかし対象路線は事\n業者内に留まっており，また利用者毎の発着地や利用経\n路に合わせて適切なタイミングで情報を届けることが難\nしかった． \n鉄道の交通量推計に関しては様々な技術が提案されて\nいる．しかし，路線計画向けの技術は解像度が列車毎で\nはなく路線毎であること 5)，鉄道事業者のダイヤ評価向\n \n図-1 電車混雑回避ルート'
    )
```

13個の`Document`オブジェクトが返され（＝つまり13個のチャンクが生成され）、`page_content`には抽出・分割されたPDFテキスト、`metadata`にはそのファイル名やページ数などの情報が格納されています。


## 結論
先に簡単に結論をまとめます。
結論① デフォルトでは何文字に分割される？そもそも`chunk_size`で指定する数は文字数なのか？
→ページごとに`chunk_size = 4000`（=4000文字）以下となるように区切られたチャンクが作成される
結論② 違うページのテキストが同じチャンクになることはあるのか？
→ない

## ①デフォルトでは何文字に分割される？そもそも`chunk_size`で指定する数は文字数なのか？
結論: ページごとに`chunk_size = 4000`（=4000文字）以下となるように区切られたチャンクが作成される
### 結論①の詳細と根拠となったコード
#### どのソースコードを確認するか

ドキュメントにも記載がある通り、`load_and_split()`では`text_splitter = None`（デフォルト）の場合、`RecursiveCharacterTextSplitter`が使われます。
この`RecursiveCharacterTextSplitter`は`TextSplitter`を継承しているので、`TextSplitter`のソースを確認します。
```python:TextSplitter（一部抜粋）
class TextSplitter(BaseDocumentTransformer, ABC):
    """Interface for splitting text into chunks."""


    def __init__(
        self,
        chunk_size: int = 4000,
        chunk_overlap: int = 200,
        length_function: Callable[[str], int] = len,
        keep_separator: Union[bool, Literal["start", "end"]] = False,
        add_start_index: bool = False,
        strip_whitespace: bool = True,
    )
```
#### TextSplitterを確認
`chunk_size: int = 4000`と記載があり、`length_function=len`が指定されています。これらの変数を使ってのちのち`TextSplitter`のクラス内メソッドである`_merge_splits()`でチャンクが生成されるので、結論4000文字以下になるように分割されるとわかります。

#### 実装して確認①
先ほどサンプルコードで実装したPDFを使って、すべてのチャンクが4000文字以下になっているか確認します。
```python:各チャンクの文字数を確認
for i in range(len(docs)):
    print(f'chunk{i}の文字数: {len(docs[i].page_content)}')
```
【出力結果】
```
chunk0の文字数: 1241
chunk1の文字数: 2160
chunk2の文字数: 1145
chunk3の文字数: 79
chunk4の文字数: 622
chunk5の文字数: 632
chunk6の文字数: 2168
chunk7の文字数: 546
chunk8の文字数: 1098
chunk9の文字数: 1303
chunk10の文字数: 1148
chunk11の文字数: 2064
chunk12の文字数: 2653
```
すべて4000文字以下になっていることが確認できました。

#### 実装して確認①-2【`chunk_size`=500で確認】
チャンクはPDFのページごとに生成されており、どのページもたまたま4000文字以下だった可能性もあるので、`chunk_size`を変えて確認もしてみます。

```python:chunk_sizeを500文字に設定
from langchain_text_splitters import RecursiveCharacterTextSplitter

# chunk_size を設定
custom_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,        # チャンクサイズを500文字に設定
)

# load_and_split に渡す
custom_docs = loader.load_and_split(text_splitter=custom_splitter)
print(f'{len(custom_docs)}個のDocumentオブジェクトが生成')
for i in range(len(custom_docs)):
    print(f'chunk{i}の文字数: {len(custom_docs[i].page_content)}')
```

【出力結果】
```
57個のDocumentオブジェクトが生成
chunk0の文字数: 472
chunk1の文字数: 444
chunk2の文字数: 473
chunk3の文字数: 389
chunk4の文字数: 480
chunk5の文字数: 477
chunk6の文字数: 481
chunk7の文字数: 473
chunk8の文字数: 491
chunk9の文字数: 488
・・・
```
結果は一部割愛しますが、`chunk_size`が4000→500と小さくなった分チャンク数も増えて、各チャンクの文字数も指定した500よりも小さい結果が得られました。

## ②違うページのテキストが同じチャンクになることはあるのか？
結論: ない
### 結論②の詳細と根拠となったコード
#### chunkを生成する流れ
`load_and_split()`の中で、指定された`chunk_size`に合わせてchunkを生成する工程は`split_text()`（実際は`_split_text()`）という関数の中で行われています。この`split_text()`が呼び出されているメソッド`create_documents()`を見てみると、以下で参照しているコードの10行目で`split_text()`が呼び出されており、変数`text`に対して処理を行っています。`text`はfor文で処理されている1つの要素なので、リテラブルなオブジェクトである`texts`がページ単位でテキストが格納されているリストなのかを確認します。

```python:create_documents()
    def create_documents(
        self, texts: List[str], metadatas: Optional[List[dict]] = None
    ) -> List[Document]:
        """Create documents from a list of texts."""
        _metadatas = metadatas or [{}] * len(texts)
        documents = []
        for i, text in enumerate(texts):
            index = 0
            previous_chunk_len = 0
            for chunk in self.split_text(text):
                metadata = copy.deepcopy(_metadatas[i])
                if self._add_start_index:
                    offset = index + previous_chunk_len - self._chunk_overlap
                    index = text.find(chunk, max(0, offset))
                    metadata["start_index"] = index
                    previous_chunk_len = len(chunk)
                new_doc = Document(page_content=chunk, metadata=metadata)
                documents.append(new_doc)
        return documents
```
#### 変数textsとは何か？
上記の`create_documents()`を呼び出している`split_documents()`を確認すると、変数`texts`には`doc.page_content`によって読み込んだテキストが格納されています。

```python:split_documents()
    def split_documents(self, documents: Iterable[Document]) -> List[Document]:
        """Split documents."""
        texts, metadatas = [], []
        for doc in documents:
            texts.append(doc.page_content)
            metadatas.append(doc.metadata)
        return self.create_documents(texts, metadatas=metadatas)
```
長くなってしまうので詳細は割愛するのですが、`documents`にはページごとの`metadata`と`page_content`が格納されているので、ループ内の`doc`はそれぞれのページの情報を表す1つの要素となります。`doc.page_content`でページごとのテキスト情報を取得することができるので、変数`texts`はページごとのテキストが格納されたリストだとわかります。
結論`split_text()`にはページごとのテキストが渡され、そのテキストから`chunk_size`や`chunk_overlap`といった引数に合わせてチャンクが生成されるので、ページをまたがったチャンクは生成されないということがわかります。

#### 補足: Documentオブジェクトとは
Documentオブジェクトを理解したい方は以下のドキュメントを参考にしてみてください。`.page_content`や`.metadata`などで何が出力されるかなどを確認すると理解が深まると思います。

- https://python.langchain.com/api_reference/core/documents/langchain_core.documents.base.Document.html
- https://python.langchain.com/docs/integrations/document_loaders/pymupdf/#initialization

#### 実装して確認②
確認のため、先ほどから使っているPDFでページが変わると別のチャンクになっているか確認してみようと思います。（`chunk_size=500`で分割)

・1~2ページ目を確認
```python:
custom_docs[3].page_content
```
【出力結果】
```
である．2016 年には，「満員電車をゼロへ」を公約 1)に\n掲げた小池百合子が東京都知事に当選し「快適通勤ムー\nブメント」2)を開始していることからも，混雑対策への\n社会的な要請が高いことが伺える． \n混雑対策のためには，鉄道利用者に対する混雑状況の\n「見える化」を通じた混雑分散やサービス設計の重要性\nが国交省答申 3)においても指摘されている．鉄道事業者\nは，独自のアプリ 4)やWebサイト上で混雑率情報を公開\nし混雑の平準化に取り組んでいる．しかし対象路線は事\n業者内に留まっており，また利用者毎の発着地や利用経\n路に合わせて適切なタイミングで情報を届けることが難\nしかった． \n鉄道の交通量推計に関しては様々な技術が提案されて\nいる．しかし，路線計画向けの技術は解像度が列車毎で\nはなく路線毎であること 5)，鉄道事業者のダイヤ評価向\n \n図-1 電車混雑回避ルート
```
```python:
custom_docs[4].page_content
```
【出力結果】
```
\nけの技術は対応路線が限定的であること 6)，時空間ネッ\nトワークを用いたシミュレーション 7)は最新の時刻表に\n適用した運用に課題があった．そこで筆者らは，各列車\nの停車駅ごとの混雑度をシミュレーションをベースに推\n定する「電車混雑予測」を開発し，乗換検索アプリを通\nじた混雑情報の提供を2015 年4 月より行っている 8)． \n2017 年3 月には，混雑度表示対象を朝ラッシュ方向54\n路線から平日終日両方向65 路線とし，通常経路よりも\nさらに混雑の少ないルートを追加表示する「電車混雑回\n避ルート」の提供も開始した 9)．「電車混雑回避ルー\nト」は図1のように表示される．また混雑度情報を考慮\nしたユーザの経路選択モデルについても分析を進めてお\nり，年間17億回，運賃料金表示額にして1.7兆円もの鉄\n道経路が検索される乗換検索アプリにおいて混雑情報の\n提供を行うことが，混雑分散，閑散路線の収益改善を促\nす可能性が示唆されている 10)． \n以上の背景のもと本研究の目的は，平日終日両方向に\n対応した「電車混雑予測」による情報提供が混雑分散お
```
・8~9ページ目を確認
```python:
custom_docs[28].page_content
```
【出力結果】
```
タを分割し，各々のデータを用いてパラメータ推定を行\nった．結果は表-5 に記載した．本研究においては，確保\nできたサンプル数の都合上，おすすめ順・時間短い順で\nの推定結果のみ分析対象とする．両者の推定結果を比較\nすると，時間短い順の時間価値がおすすめ順の約2倍程\n度となっており，ユーザの志向が推定結果に現れたとい\nえる．また，時間短い順では混雑度表示ダミーの影響も\n強く受けていることが読み取れる．一方混雑不効用につ\nいてはおすすめ順の方が影響を強く受けている．
```
```python:
custom_docs[29].page_content
```
【出力結果】
```
9 \n表-2  全データ利用時の結果 \n \n \n推定値 \nt値 \n乗換回数 [回] \n-0.90 \n-50.37 \n運賃 [100円] \n-0.44 \n-35.41 \n混雑度2乗×所要時間 \n[10分×混雑^2] \n-0.0067 \n-13.71 \n所要時間 [10分] \n-0.99 \n-45.84 \n待ち時間 [10分] \n-0.96 \n-47.34 \n混雑度表示ダミー \n0.50 \n8.48 \n第一経路ダミー \n0.88 \n52.59 \nサンプル数 \n31934 \n修正済み尤度比 \n0.27 \n乗換抵抗 [分/回] \n9.09 \n所要時間価値 [円/時間] \n1345.74 \n待ち時間価値 [円/時間] \n1308.93 \n混雑度表示価値  [分] \n5.05 \n第一経路表示価値  [分] \n8.89 \n混雑不効用 \n[分] \n[所要時間30分 混雑度1→2] \n0.61 \n混雑不効用 \n[分] \n[所要時間30分 混雑度4→5] \n1.83
```
`custom_docs[3]`でPDF1ページ目の内容は終わり、`custom_docs[4]`からは2ページ目の内容が始まっています。
たまたまかもしれないので、もう1ページ分確認しましたが、custom_docs[28]でPDF8ページ目の内容は終わり、`custom_docs[29]`からは9ページ目の内容が始まっているとわかり、ページが違うと別のチャンクになるとわかりました。

## ソースコード一覧
参考にしたソースコードとそのURLを記載します。
- `load_and_split()`: `BaseLoader`
https://python.langchain.com/api_reference/_modules/langchain_core/document_loaders/base.html#BaseLoader.load_and_split
- `split_documents()`: `TextSplitter`
https://python.langchain.com/api_reference/_modules/langchain_text_splitters/base.html#TextSplitter.split_documents
- `create_documents()`: `TextSplitter`
https://python.langchain.com/api_reference/_modules/langchain_text_splitters/base.html#TextSplitter.create_documents
- `split_text()`: `RecursiveCharacterTextSplitter`
https://python.langchain.com/api_reference/_modules/langchain_text_splitters/character.html#RecursiveCharacterTextSplitter.split_text
- `_split_text()`: `RecursiveCharacterTextSplitter`
https://python.langchain.com/api_reference/_modules/langchain_text_splitters/character.html#RecursiveCharacterTextSplitter
- `_split_text_with_regex()`
https://python.langchain.com/api_reference/_modules/langchain_text_splitters/character.html
- `_merge_splits()`: `TextSplitter`
https://python.langchain.com/api_reference/_modules/langchain_text_splitters/base.html#TextSplitter


## 最後に
ソースコードをじっくり読んだのは初めてだったので、とても大変でしたが勉強になりました。
どなたかの参考になれば幸いです！