RAG(Retrieval-Augmented Generation)を実装する際、ドキュメントのチャンキングは非常に重要な工程です。
その中でも、LangChainの RecursiveCharacterTextSplitter はよく使われるツールの一つで、
チャンクサイズとセパレータを指定することで、うまくテキストを分割してくれます。

 

このRecursiveCharacterTextSplitterには、分割後の文章の重なり具合を決める「オーバーラップサイズ」を指定することもでき、チャンク間で前後の文脈がなるべく失われないように分割することが可能です。
多くの場合はこのオーバーラップを有効にして使用すると思います。

 

実はこのオーバーラップ、どのチャンクに対しても必ず発生するわけではありません。
というのも、オーバーラップは「目標」であり、基本的にはセパレータによる分割が優先されるためです。
この点を把握せずに「必ずオーバーラップする前提」で設計すると、データによっては精度に影響する可能性があります。

 

本記事では、この挙動について実装レベルで整理しつつ、

 

  • なぜオーバーラップが発生しないのか
  • どんな条件で発生しないか

 

を具体例とともに解説していきます。

 

本記事の想定読者は以下の通りです。

  • 「chunk_overlapを指定しているのにうまく効いていない気がする」と感じたことがある方
  • LangChainをを使用しており、RecursiveCharacterTextSplitterの挙動をより深く理解したい方

 

※ 本記事の内容は、全て執筆時点におけるLangChainの実装に基づくものです。
※ 本記事では「オーバーラップ」と「重複」を同じ意味で使用しており、見やすさや文脈により使い分けることがあります。
 

目次

  1. まずは結論
  2. RecursiveCharacterTextSplitter の基本動作
  3. オーバーラップが発生しない実例①
  4. オーバーラップが発生しない実例②
  5. まとめ

1. まずは結論

詳しい説明は後にして、まずはどのような時にオーバーラップが発生しないか、具体例をお見せします。
主に次の2つのパターンで発生します。

パターン①:分割単位が異なる場合

RecursiveCharacterTextSplitter は、あらかじめ指定された区切り文字(separator)に基づいて、大きな単位から順に分割していきます。
例えば、以下のような順序で分割するとします。

 

["\n\n", "\n", " ", ""]

 

そして、次の文章を分割するとします。

 

"""
この1行目の文章と2行目の文章は改行で区切られており、(\n)
テキスト分割すると1行目と2行目の間でオーバーラップは発生しません。
"""

 

このとき、chunk_size=20, chunk_overlap=5で分割すると、

 

[
 'この1行目の文章と2行目の文章は改行で区', 'は改行で区切られており、',
 'テキスト分割すると1行目と2行目の間で', '行目の間でオーバーラップは発生しません。'
]

 

という分割結果になります。

 

上記の通り、1行目の末尾(~で区切られており)と2行目の先頭(テキスト分割すると~)は重複していないことが分かります。

パターン②:分割後の要素がオーバーラップサイズを超えてしまう場合

例えば、次の文章を分割するとします。

 

"""
この文章は全て空白区切りです。 各文はオーバーラップサイズを超えています。 それぞれの文の間で重複は発生しません。
"""

 

これを、chunk_size=50, chunk_overlap=10で分割すると、

 

[
 'この文章は全て空白区切りです。 各文はオーバーラップサイズを超えています。',
 'それぞれの文の間で重複は発生しません。'
]


となり、重複は全くないことが分かります。

 

次に、chunk_size=50, chunk_overlap=30で分割してみると、

 

[
 'この文章は全て空白区切りです。 各文はオーバーラップサイズを超えています。',
 '各文はオーバーラップサイズを超えています。 それぞれの文の間で重複は発生しません。'
]

 

となり、今度は一文全体を重複する結果となりました。

2. RecursiveCharacterTextSplitterの基本動作

まずは、RecursiveCharacterTextSplitter がどのようにテキストを分割しているのか、基本的な挙動を整理します。
典型的な使い方は以下の通りです。

 

from langchain_text_splitters import RecursiveCharacterTextSplitter

 

source_text = "分割したいテキスト"

 

chunk_size = 300
chunk_overlap = 30

 

splitter = RecursiveCharacterTextSplitter(
    chunk_size=chunk_size,
    chunk_overlap=chunk_overlap,
    separators=["\n\n", "\n", " ", ""],
)

 

split_texts = splitter.split_text(source_text)
print(split_texts)

 

主なパラメータ

  • chunk_size
    1チャンクあたりの最大サイズです。(サイズの測り方はlength_functionで指定可能)
    このサイズを超える場合、テキストはさらに分割されます。
     
  • chunk_overlap
    チャンク同士をどれくらい重ねるかを指定します。
    ただし、本記事の主題でもある通り、常に適用されるわけではありません。
     
  • separators
    分割に使用する区切り文字の優先順位です。


    ["\n\n", "\n", " ", ""]
     

    上から順に、
     

    1.    まずは段落(”\n\n”)で分割を試す
    2.    うまくいかなければ改行(”\n”)
    3.    さらにダメなら空白(” “)
    4.    最後は1文字単位("")

 

というように、できるだけ意味のある単位を保ちながら分割する設計になっています。

 

分割の流れ
RecursiveCharacterTextSplitter の挙動は、以下のような流れで行われます。
 

  1. 指定された separators のうち、現在のテキストに適用可能なものを選択
  2. その区切りでテキストを分割
  3. 分割された各要素について、chunk_size を超えているかを判定
  4. 超えている場合のみ、より細かい区切りで再帰的に分割
  5. chunk_size 以下の要素はそのままチャンク候補として扱う

 

ポイント:常に細かく分割されるわけではない

 

ここで重要なのは、「必要なときだけ分割する」ことと、「再帰的に分割する」ことです。
つまり、

 

  • より粗い区切りでうまく分割できた場合
    → 細かい区切りは使わない
  • 粗い区切りだと大きすぎる場合
    → 細かい区切りによる分割処理に任せる

 

という挙動になります。

3. オーバーラップが発生しない実例①

まず1つ目のパターンは、異なる単位の区切りが適用された場合に、オーバーラップが発生しないケースです。
RecursiveCharacterTextSplitter は、指定された separators に基づいて、大きな単位から順に分割していきます。

 

例えば、2章で試した以下の設定を考えます。

 

from langchain_text_splitters import RecursiveCharacterTextSplitter

 

source_text = """この1行目の文章と2行目の文章は改行で区切られており、
テキスト分割すると1行目と2行目の間でオーバーラップは発生しません。
"""

 

chunk_size = 20
chunk_overlap = 5

 

splitter = RecursiveCharacterTextSplitter(
    chunk_size=chunk_size,
    chunk_overlap=chunk_overlap,
    separators=["\n\n", "\n", " ", ""],
)

 

split_texts = splitter.split_text(source_text)
print(split_texts)

 

このとき、分割結果は次のようになります。

 

[
 'この1行目の文章と2行目の文章は改行で区', 'は改行で区切られており、',
 'テキスト分割すると1行目と2行目の間で', '行目の間でオーバーラップは発生しません。'
]

 

上記の通り、1行目の末尾(~で区切られており)と2行目の先頭(テキスト分割すると~)は重複していません。

 

なぜオーバーラップしないのか

 

これは、次のような処理の流れによるものです。

 

  1. まず改行("\n")でテキストが分割される。
    → 1行目と2行目が別の塊として扱われる。
  2. 各行が chunk_size を超えているため、1文字単位("")でさらに分割される。
    その結果、
     ▷ 1行目由来のチャンク
     ▷ 2行目由来のチャンク
    がそれぞれ独立して生成される。
    → 異なる分割単位(今回でいう「行」)をまたいで再結合されることはない。

 

ここで、実際のソースコードを見てみます。
以下は、LangChain - character.pyのソースコードの一部を引用したものです。

 

good_splits = []
separator_ = "" if self._keep_separator else separator
for s in splits:
    if self._length_function(s) < self._chunk_size:
        good_splits.append(s)
    else:
        if good_splits:
            merged_text = self._merge_splits(good_splits, separator_)
            final_chunks.extend(merged_text)
            good_splits = []
        if not new_separators:
            final_chunks.append(s)
        else:
            other_info = self._split_text(s, new_separators)
            final_chunks.extend(other_info)
if good_splits:
    merged_text = self._merge_splits(good_splits, separator_)
    final_chunks.extend(merged_text)
return final_chunks

(出典:https://github.com/langchain-ai/langchain/blob/master/libs/text-splitters/langchain_text_splitters/character.py) 

 

上記は、RecursiveCharacterTextSplitter クラスにおける分割処理の一部です。
for文で使われているsplitsは、ある区切り文字で分割済みのテキストのリストです。

 

_merge_splits()は分割済みの各テキストが既にチャンクサイズ内の場合に、オーバーラップを考慮しながら、各テキストをチャンクサイズに収まるように結合するメソッドです。
一方、_split_text()は上記の分割処理を行っているメソッド自身で、分割済みテキストがチャンクサイズを超えている場合に、さらに細かい区切り文字で再帰的に分割を試みます。

 

ここで重要なのは、オーバーラップが発生するのは_merge_splits()が呼び出された場合のみ、ということです。
先ほどの例で考えると、1行目の文章(この1行目の文章~)と、2行目の文章(テキスト分割すると~)は、
どちらも改行(\n)区切りの時点ではチャンクサイズを超えるため、それぞれ_split_text()により再帰処理されます。
すると、より細かい単位("")で分割されますが、そのタイミングで_merge_splits()が実行されます。

 

つまり、1行目の塊と2行目の塊は別々にオーバーラップが適用され、その間では発生しないという挙動になります。

4. オーバーラップが発生しない実例②

2つ目のパターンは、分割後の要素のサイズと chunk_overlap の関係によって、オーバーラップが発生しないケースです。

 

こちらも、2章で試した設定を使って見ていきます。

 

from langchain_text_splitters import RecursiveCharacterTextSplitter

 

source_text = """この文章は全て空白区切りです。 各文はオーバーラップサイズを超えています。 それぞれの文の間で重複は発生しません。"""

 

chunk_size = 50
chunk_overlap = 10

 

splitter = RecursiveCharacterTextSplitter(
    chunk_size=chunk_size,
    chunk_overlap=chunk_overlap,
    separators=["\n\n", "\n", " ", ""],
)

 

split_texts = splitter.split_text(source_text)
print(split_texts)

 

このときの分割結果は次の通りです。

 

[
 'この文章は全て空白区切りです。 各文はオーバーラップサイズを超えています。',
 'それぞれの文の間で重複は発生しません。'
]

 

上記の結果から分かる通り、チャンク間にオーバーラップは発生していません。

 

なぜオーバーラップしないのか

 

ポイントは、分割単位となる要素のサイズです。
 

今回のケースでは、最小の分割単位が「""」ではなく「" "」のため、半角空白区切りの単位でオーバーラップが発生します。

 

ここで、オーバーラップ処理の部分について、実際のソースコードを見てみます。
以下は、LangChain - base.pyのソースコードの一部を引用したものです。(解説部分と直接関係のない箇所は省略しています。)
 

for d in splits:
    len_ = self._length_function(d)
    if (
        total + len_ + (separator_len if len(current_doc) > 0 else 0)
        > self._chunk_size
    ):
        # 一部省略

 

        if len(current_doc) > 0:
            doc = self._join_docs(current_doc, separator)
            if doc is not None:
                docs.append(doc)

 

            # 一部省略

 

            while total > self._chunk_overlap or (
                total + len_ + (separator_len if len(current_doc) > 0 else 0)
                > self._chunk_size
                and total > 0
            ):
                total -= self._length_function(current_doc[0]) + (
                    separator_len if len(current_doc) > 1 else 0
                )
                current_doc = current_doc[1:]
    current_doc.append(d)
    total += len_ + (separator_len if len(current_doc) > 1 else 0)
doc = self._join_docs(current_doc, separator)
if doc is not None:
    docs.append(doc)
return docs

(出典:https://github.com/langchain-ai/langchain/blob/master/libs/text-splitters/langchain_text_splitters/base.py)

 

上記は、ひとつ前の節で説明した_merge_splits()の処理の一部です。
この処理の中で、チャンクをちょうどいい長さにしつつ、オーバーラップの処理も行っています。
for文に使われているsplitsは、ある区切り文字で分割済みのテキストのリストです。

今回重要なのは、while文の中の処理です。
current_docがオーバーラップ部分に相当しますが、これをオーバーラップサイズに収めるために、


current_doc = current_doc[1:]

 

を繰り返し実行しています。
上記のcurrent_docも区切り文字単位のため、区切りが「""」であれば一文字単位でオーバーラップが調整されますが、
「" "」の場合は、分割後のテキストがオーバーラップサイズを超えると、その要素はオーバーラップとして保持されません。
 

 では次に、chunk_overlap を大きくしてみます。

 

from langchain_text_splitters import RecursiveCharacterTextSplitter

 

source_text = """この文章は全て空白区切りです。 各文はオーバーラップサイズを超えています。 それぞれの文の間で重複は発生しません。"""

 

chunk_size = 50
chunk_overlap = 30

 

splitter = RecursiveCharacterTextSplitter(
    chunk_size=chunk_size,
    chunk_overlap=chunk_overlap,
    separators=["\n\n", "\n", " ", ""],
)

 

split_texts = splitter.split_text(source_text)
print(split_texts)

 

上記の分割結果は次のようになります。

 

[
 'この文章は全て空白区切りです。 各文はオーバーラップサイズを超えています。',
 '各文はオーバーラップサイズを超えています。 それぞれの文の間で重複は発生しません。'
]

 
今度は、「各文はオーバーラップサイズを超えています。」の部分が重複しています。
上記の挙動からも、オーバーラップは「文字数ベースで強制的に切り出される」のではなく、「分割単位に依存する」ということが分かります。

 

上記の通り、chunk_overlap は単純に「指定した文字数分だけ重なる」わけではなく、
分割された要素のサイズによって挙動が変わる点に注意が必要です。

5. まとめ

本記事では、RecursiveCharacterTextSplitter において、chunk_overlap を指定していてもオーバーラップが発生しないケースについて解説しました。

 

ポイントを整理すると、以下の通りです。

  • chunk_overlap は常に適用されるわけではない
  • オーバーラップが発生しない主なパターンは次の2つ
     ▷ 分割単位が異なる場合(改行や段落などの境界をまたがない)
     ▷ 分割後の要素サイズと chunk_overlap の関係による場合

 

RecursiveCharacterTextSplitter は非常に便利なコンポーネントですが、
「適切にオーバーラップさせてくれるもの」と捉えて使っていると、意図しない挙動に直面する可能性があります。
 

特にRAGのように、チャンキングの仕方がそのまま精度に影響するケースでは、この挙動を正しく理解しておくことが重要です。
チャンキングの結果は一度ログなどで確認し、意図した分割になっているかをチェックすることをおすすめします。
 

その上で、

  • chunk_size や chunk_overlap の調整
  • separators の見直し

などを行うことで、より安定したチャンキング設計が可能になります。

 

本記事が、RecursiveCharacterTextSplitter の挙動理解の一助になれば幸いです。
 

------------------------------
This article includes code from LangChain (MIT License).
License: https://github.com/langchain-ai/langchain/blob/master/LICENSE
------------------------------