プログラミング

RAGのチャンキング基礎:文書を検索しやすく分割する

RAGでは、質問に関連する文書を検索してLLMに渡します。このとき、長い文書をまるごと1つのベクトルにして検索の対象にすると、複数の話題が1つのベクトルに混ざり、検索がぼやけてしまいます。そこで、文書を検索しやすい大きさに分けてから扱います。この分割をチャンキングといい、分割してできた断片をチャンクとよびます。

この記事では、チャンキングの基本的なやり方と分割の大きさが検索のあたり方にどう効くかを、コードを動かして確認します。

動作確認

Python 3.12/langchain-text-splitters 1.1.2/sentence-transformers 5.6.1/torch 2.13.0/numpy 2.5.1/tiktoken 0.13.0/anthropic 0.120.2/google-genai 2.16.0で確認しています。

本文の記述は2026年7月時点のものです。

目次

はじめに

次のライブラリを使います。

pip install langchain-text-splitters sentence-transformers tiktoken anthropic google-genai python-dotenv

# uvの場合
uv add langchain-text-splitters sentence-transformers tiktoken anthropic google-genai python-dotenv

文書を分割する理由

文章を分割する理由は、大きく次の2つです。

  • LLMやembeddingモデルに一度に渡せるテキスト量(コンテキストウィンドウ)に上限がある
  • 1つのチャンクに複数の話題が混ざっていると、そのチャンクのベクトルは複数の話題で平均される

複数の話題が平均化されると、検索の粒度が低下します。

固定長分割

最も単純な方法は、決まった文字数ごとに機械的に区切る方法です。これは固定長分割といいます。

def split_fixed(text, size, overlap=0):
    """文字数でtextをsize文字ずつに区切る。overlapは隣と重ねる文字数"""
    if overlap >= size:
        raise ValueError("overlapはsizeより小さくしてください")
    chunks, start, step = [], 0, size - overlap
    while start < len(text):
        chunks.append(text[start:start + size])
        start += step
    return chunks

text = (
    "在宅勤務制度では、週3日まで在宅で働けます。利用するには前月末までに申請してください。"
    "経費精算は毎月20日が締め切りです。専用システムから申請し、領収書の添付が必要です。"
)

for i, c in enumerate(split_fixed(text, 30)):
    print(f"[{i}] {c}")

実行すると、次の結果が得られます。

[0] 在宅勤務制度では、週3日まで在宅で働けます。利用するには前月
[1] 末までに申請してください。経費精算は毎月20日が締め切りです
[2] 。専用システムから申請し、領収書の添付が必要です。

固定長分割の問題点は言葉や文の途中で分断されることであり、分断された箇所ではどちらのチャンクでも不完全になり検索であたりにくくなります。

構造を意識した分割

固定長で分割する代わりに、文・段落・見出しといった、文章の自然な区切りを優先して分割する方法もあります。考え方としては「粗い区切りから細かい区切りに順に降りる」ことです。

例えば、まず段落(空行)で分け、それでもチャンクが大きすぎる場合は改行で分け、それでも大きければ文(句点”。”)で分け、それでも大きければ最後に文字数で分ける、というように、必要な分だけ細かくします。

この区切りを順に試して分割するやり方は、再帰的呼び出しにより実装することができますが、ライブラリを使うのが一般的です。よく使われるのはLangChainのRecursiveCharacterTextSplitterで、LangChainの公式ドキュメントでは、これを一般的なテキスト向けの推奨として挙げています。

以下にライブラリを使ったコードと自前で作成したコードの両方を記載します。

from langchain_text_splitters import RecursiveCharacterTextSplitter

# チャンキングを適用するテキスト文
text = """在宅勤務制度では、週3日まで在宅で働けます。利用するには前月末までに申請してください。

経費精算は毎月20日が締め切りです。専用システムから申請し、領収書の添付が必要です。承認は原則3営業日以内に行われます。"""

# デフォルト設定(区切り文字は["\n\n", "\n", " ", ""])
default_splitter = RecursiveCharacterTextSplitter(chunk_size=40, chunk_overlap=0)
print("=== デフォルト設定 ===")
for i, c in enumerate(default_splitter.split_text(text)):
    print(f"[{i}] {c}")

# 日本語向け
ja_splitter = RecursiveCharacterTextSplitter(
    chunk_size=40,
    chunk_overlap=0,
    separators=["\n\n", "\n", "。", "、", ""], # 区切り文字を日本語用に変更
    keep_separator="end", # 区切りに使った「。」を文の末尾に残す
)
print("\n=== 日本語向けの場合 ===")
for i, c in enumerate(ja_splitter.split_text(text)):
    print(f"[{i}] {c}")

実行すると、次の結果が得られます。

(ライブラリを使用した場合)
=== デフォルト設定 ===
[0] 在宅勤務制度では、週3日まで在宅で働けます。利用するには前月末までに申請してくだ
[1] さい。
[2] 経費精算は毎月20日が締め切りです。専用システムから申請し、領収書の添付が必要
[3] です。承認は原則3営業日以内に行われます。

=== 日本語向けの場合 ===
[0] 在宅勤務制度では、週3日まで在宅で働けます。
[1] 利用するには前月末までに申請してください。
[2] 経費精算は毎月20日が締め切りです。
[3] 専用システムから申請し、領収書の添付が必要です。
[4] 承認は原則3営業日以内に行われます。

(スクラッチのコードを使用した場合)
[0] 在宅勤務制度では、週3日まで在宅で働けます。
[1] 利用するには前月末までに申請してください。
[2] 経費精算は毎月20日が締め切りです。
[3] 専用システムから申請し、領収書の添付が必要です。
[4] 承認は原則3営業日以内に行われます。

ライブラリを用いた場合、標準の区切り文字(["\n\n", "\n", " ", ""])が、単語をスペースで区切る英語を前提にしているため、分割がおかしくなっています。

日本語向けの場合には、引数separatorsにより区切り文字としてスペースの代わりに句読点を追加し、keep_separator="end"を指定することで区切り文字をチャンクの末尾に残しています。

チャンクが句点で区切られるのか、より細かい読点などで区切られるのかは、チャンクサイズによって決まります。チャンクサイズが小さくなると、より細かい分割が行われるようになります。

オーバーラップ

文の途中が切れていなくても、文章をチャンクに分けると隣り合うチャンクの境界では、前後のつながりが切れてしまいます。たとえば、チャンクのはじまりが「その原理に基づくと、人口は減り続けることになる」と書いてあった場合、その原理が何を意味しているかをそのチャンクから知ることができません。

これを和らげるのがオーバーラップです。「隣り合うチャンクがお互いに重なり合うように区切る」ことで、境界部分の文脈を両方のチャンクに含める方法です。

LangChainのRecursiveCharacterTextSplitterでは、これまで0を指定してきたchunk_overlapという引数がそれにあたります。

from langchain_text_splitters import RecursiveCharacterTextSplitter

text = (
    "在宅勤務制度では、週3日まで在宅で働けます。利用するには前月末までに申請してください。"
    "経費精算は毎月20日が締め切りです。専用システムから申請し、領収書の添付が必要です。"
    "承認は原則3営業日以内に行われます。"
)

overlap_splitter = RecursiveCharacterTextSplitter(
    chunk_size=60,
    chunk_overlap=25,
    separators=["\n\n", "\n", "。", "、", ""],
    keep_separator="end",
)

for i, c in enumerate(overlap_splitter.split_text(text)):
    print(f"[{i}] {c}")

実行結果は次のようになります。

[0] 在宅勤務制度では、週3日まで在宅で働けます。利用するには前月末までに申請してください。
[1] 利用するには前月末までに申請してください。経費精算は毎月20日が締め切りです。
[2] 経費精算は毎月20日が締め切りです。専用システムから申請し、領収書の添付が必要です。承認は原則3営業日以内に行われます。

隣接するチャンクで情報が重複していることを確認できます。オーバーラップは文字数で単純に末尾を切り出すのではなく、チャンクを構成していた断片を丸ごとchunk_overlapに収まる分だけ次のチャンクの先頭に持ち越す、という形で働きます。

オーバーラップを行うと、前後の文脈を保つようになりますが、その分、「保存や検索、LLMへの入力にかかるコストが増える」というデメリットもあります。

チャンクサイズと検索精度のトレードオフ

チャンクサイズは検索の結果を左右する重要なパラメータです。

チャンクサイズが小さいと、1つのチャンクの話題が絞られるため、その話題を探す意味検索にヒットしやすくなる傾向があります。その反面、1つのチャンクに収まる文脈は減るため、クエリに対する回答が複数のチャンクにまたがる場合には、必要な情報が拾えきれないことがあります。

逆に、チャンクサイズが大きいと、1つのチャンクに複数の話題が入る可能性が高くなるため、複数の話題が混ざってベクトルがぼやけ、知りたい話題を探す意味検索にヒットしにくくなる傾向があり、トークンも増えるためコストも増加します。その反面、文脈が豊富にあるため、正しくヒットすればその豊富な文脈から回答に必要な情報を得やすくなります

どちらが良いかは文章の性質や用途によって変わるため、実際に動かして検索の精度を測り、必要ならサイズを調整するという形になります。

実際に、同じ文章を異なるチャンクサイズで分割して、意味検索の結果を比較してみます。検索には、文章をベクトルにしてコサイン類似度で探す意味検索を用います。仕組みは別記事「Embeddingモデルを用いて文をベクトルに変換する」で解説しています。

import numpy as np
from sentence_transformers import SentenceTransformer
from langchain_text_splitters import RecursiveCharacterTextSplitter

model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")

def cosine(a, b):
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

def search(query, chunks, top_k=2):
    q = model.encode(query)
    embs = model.encode(chunks)
    scored = [(cosine(q, embs[i]), chunks[i]) for i in range(len(chunks))]
    scored.sort(reverse=True, key=lambda x: x[0])
    return scored[:top_k]

# 在宅勤務の情報が、他の話題に囲まれた知識ベース
text = (
    "経費精算は毎月20日が締め切りです。"
    "有給休暇は入社時に10日付与されます。"
    "在宅勤務は週3日まで利用できます。"
    "健康診断は毎年11月に実施します。"
    "オフィスは東京・大阪・福岡の3拠点です。"
)
query = "在宅勤務は週に何日できますか?"

for size in [20, 40]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=size,
        chunk_overlap=0,
        separators=["\n\n", "\n", "。", "、", ""],
        keep_separator="end",
    )
    chunks = splitter.split_text(text)
    print(f"\n=== チャンクサイズ {size}字 ===")
    for score, c in search(query, chunks):
        print(f"類似度 {score:.3f}  {c}")

「在宅勤務は週に何日できますか?」という質問にたいして、結果は以下となりました。

=== チャンクサイズ 20字 ===
類似度 0.815  在宅勤務は週3日まで利用できます。
類似度 0.534  有給休暇は入社時に10日付与されます。

=== チャンクサイズ 40字 ===
類似度 0.616  在宅勤務は週3日まで利用できます。健康診断は毎年11月に実施します。
類似度 0.532  経費精算は毎月20日が締め切りです。有給休暇は入社時に10日付与されます。

チャンクサイズが小さい方が類似度が高くなっています。これにより、チャンクサイズを大きくすると、在宅勤務に健康診断の情報が混ざることで類似度が低下していることを確認できました。

ここで挙げた具体的な数値(0.815や0.616)は、このデータ・このモデル・このクエリでの結果です。別のデータやモデルでは値そのものは変わります。

1つのチャンクに複数の話題を詰め込むほど、そのベクトルは個々の話題を薄めた平均に近づき、特定の話題への反応が弱まる傾向はありますが、データによってはその逆の結果になることもあります。

日本語のチャンキングでの注意点

日本語を分割するときには、英語とは異なる点がいくつかあります。

相違点内容
文の区切り英語はスペース区切りですが、日本語は句点(。)などを区切りとします。ライブラリは外国製のものがほとんどなので注意が必要です。
トークンの密度日本語と英語ではトークン量が異なる場合があります。どちらが多くなるかはモデルによって異なります。
表記の揺れ全角と半角が混ざっていたり、同じ語に複数の書き方があると、検索でうまくいかないことがあるため、表記を統一する正規化を行うと、検索精度が改善することがあります。

精度を上げたいときの選択肢

句点や段落などの構造を手がかりにした再帰的分割とオーバーラップの組み合わせは、多くの場合にRAGの出発点として標準的な方法ですが、精度を上げるためにチャンキングの他の方法も提案されています。

方法概要デメリット
セマンティックチャンキング文字数ではなく、話題の切れ目で区切る分割。隣り合う文の意味が離れたところをチャンクの境界にすることで、1つのチャンクの中身を揃え、検索の的中を上げるのが狙い。文ごとに埋め込み計算のコストがかかり、改善幅は数%程度にとどまることも多い。
階層的チャンキング小さいチャンクで検索し、当たったら、その周辺を含む大きいチャンクをLLMに渡す方式。検索は精密に、生成には十分な文脈を、という両取りを狙う。小さいチャンクと大きいチャンクの両方を保存・管理する必要がある。
contextual retrieval各チャンクの先頭に「この断片が文書全体の中で何の話か」という短い説明をLLMで付けてから埋め込む手法。断片だけでは意味が取れず検索に外れる問題を減らす。チャンクごとにLLM呼び出しのコストがかかる。
late chunkingチャンクごとにベクトル化するのではなく、embeddingモデルが一度に処理できる文書をまとめてトークン化してモデルに1度で入力し、それにより個々のトークンをベクトル化する。まとめてモデルに入力するので、ここのベクトルは周囲の文脈も含んでいる。その後にチャンクの区切りに沿って、その範囲のトークンのベクトルを平均し、チャンクごとのベクトルを作る。これにより、チャンクに周辺の文脈を持たせることを狙う。長い文脈に対応したembeddingモデルが前提で、対応モデルの選択肢が狭まる。

トークン数を数える

トークンは、LLMが文章を処理するときの文字のかたまりの単位で、トークンへの分割を行うトークナイザにより異なります。料金やコンテキストウィンドウの上限もトークンで数えます。embeddingモデルに入力できる文字数の上限もトークンで決まります。

モデルが扱えるトークン数を超えた入力をした場合、超えた部分が検索から欠落する可能性があります。

トークンの数え方は、モデル・提供元ごとに異なります。ここでは、この記事で使ってきたsentence-transformersと、主要な商用モデル3社(OpenAI・Claude・Gemini)について方法をまとめます。

sentence-transformers

SentenceTransformerで読み込んだモデルには、そのモデル専用のトークナイザと最大入力長が、追加のライブラリなしで最初から入っています。

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")

print(model.max_seq_length)  # このモデルが一度に処理できる上限(トークン数)
print(len(model.tokenizer.encode("在宅勤務は週3日まで利用できます。")))  # 実際のトークン数

OpenAI

tiktokenというOpenAI公式のライブラリで数えます。オフラインで動作します。

import tiktoken

enc = tiktoken.encoding_for_model("gpt-4o")  # モデル名から自動でエンコーディングを選ぶ
print(len(enc.encode("在宅勤務は週3日まで利用できます。")))

Claude(Anthropic)

Claudeのトークナイザは公開されていません。tiktokenのようなオフラインのライブラリは公式には存在せず、代わりにcount_tokensというAPIを呼びます。ネットワークに接続する必要があります。APIキーが必須です(設定方法は補足に記述)。

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()  # .envから各社のAPIキーを読み込む

client = Anthropic()
response = client.messages.count_tokens(
    model="claude-sonnet-4-5",
    messages=[{"role": "user", "content": "在宅勤務は週3日まで利用できます。"}],
)
print(response.input_tokens)

Gemini(Google)

GeminiにもcountTokensという専用のAPIがあります。APIを使用しているため、ネットワークに接続する必要があります。APIキーが必須です(設定方法は補足に記述)。

from google import genai
from dotenv import load_dotenv

load_dotenv()  # .envから各社のAPIキーを読み込む

client = genai.Client()
response = client.models.count_tokens(
    model="gemini-2.5-flash",
    contents="在宅勤務は週3日まで利用できます。",
)
print(response.total_tokens)

補足

APIキーを使用する場合には.envに環境変数を設定する必要があります。

ANTHROPIC_API_KEY=取得したキー
GEMINI_API_KEY=取得したキー

APIキーについては別記事「LLM APIの呼び出しとAPIキー管理:Gemini・GPT・Claude」で解説しています。

まとめ

チャンキングの基本と、チャンクサイズが検索に効くことを、実際に動かしながら確認しました。要点は次のとおりです。

  • 文書を分割するのは、コンテキストウィンドウの制約に対応し、検索の粒度を上げるためです。
  • 文や段落など自然な区切りを優先する構造を意識した分割にすると、文がチャンクサイズに収まる限り途中で切れにくくなり、各チャンクが意味のまとまりになりやすくなります。
  • オーバーラップは、隣り合うチャンクを少し重ねて境界の文脈のつながりを保ちます。構造を意識した再帰的分割にオーバーラップを組み合わせるのが標準的です。
  • チャンクサイズは検索精度と文脈の豊富さの間のトレードオフです。
  • 日本語では、トークン密度・文の区切り・表記の揺れに注意します。

参考文献

-プログラミング
-, ,