RAGでは、質問に関連する文書を検索してLLMに渡します。このとき、長い文書をまるごと1つのベクトルにして検索の対象にすると、複数の話題が1つのベクトルに混ざり、検索がぼやけてしまいます。そこで、文書を検索しやすい大きさに分けてから扱います。この分割をチャンキングといい、分割してできた断片をチャンクとよびます。
この記事では、チャンキングの基本的なやり方と分割の大きさが検索のあたり方にどう効くかを、コードを動かして確認します。
本文の記述は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
以下にライブラリを使ったコードと自前で作成したコードの両方を記載します。
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}")import re
def _split_by_structure(text, size, sep, keep_separator="start"):
"""textを、sep(区切り文字)で断片に分ける。区切り文字は前の断片の末尾に残す。"""
if sep == "":
return list(text)
if keep_separator == "end":
pieces = re.split(f"(?<={re.escape(sep)})", text) # 直前がsepである位置で分割
else:
pieces = re.split(f"(?={re.escape(sep)})", text) # 直後がsepである位置で分割
return [p for p in pieces if p] # 空文字列だけ除外
def _merge_pieces(pieces, size, overlap):
"""piece(断片)のリストを、sizeに収まる範囲でチャンクにまとめる"""
chunks = [] # 完成したチャンクをためるリスト
current = [] # 今組み立て中のチャンクを構成する断片のリスト
total = 0 # 今組み立て中のチャンクの長さ
for piece in pieces:
# 断片をひとつずつ取り出す
length = len(piece)
if total + length > size:
# 今扱っている断片をチャンクに追加するとサイズオーバーになる場合
if current:
text = "".join(current).strip()
# 今組み立て中のチャンクの断片リストを結合してチャンクとする
if text:
chunks.append(text) # 完成したチャンクを格納する
while total > overlap or (total + length > size and total > 0):
# <オーバーラップ処理の実行>
# 完成したチャンクの断片リストから先頭の断片を取り除く処理を、
# チャンクの断片リストの長さがオーバーラップ長さよりも小さく、且つ、
# 断片pieceを足しても、長さがサイズを超えないようになるまで繰り返す
# ただし、取り除くものがなくなった場合は終了する
# この結果、削除されずに残った部分が次のチャンクの先頭に追加される
total -= len(current[0])
current = current[1:]
current.append(piece) # 今組み立て中のチャンクに今着目している断片を追加
total += length # 今組み立て中のチャンクの長さを更新
if current:
# すべての断片を処理した後に、余った部分をチャンクとしてリストに格納する
text = "".join(current).strip()
if text:
chunks.append(text)
return chunks # 作成したチャンクリストを返す
def split_recursive(text, size, overlap=0, seps=("\n\n", "\n", "。", "、", ""), keep_separator="start"):
"""段落→改行→句点→読点→文字の順に、サイズに収まるまで分割する。"""
sep, rest = seps[0], seps[1:]
# 今回試す区切り文字(sep)と、まだ試していない残りの区切り文字(rest)に分ける
pieces = _split_by_structure(text, size, sep, keep_separator) # 入力されたテキストを最初の区切り文字で分割する
good, chunks = [], []
# goodeは単体でsize未満に収まる断片をためるリスト
# chunksは最終的なチャンクをためるリスト
for piece in pieces:
# 断片を1つずつ取り出す
if len(piece) < size:
# 断片の長さがsize未満の場合には、断片をgoodリストにためておく
good.append(piece)
else:
# 断片の長さがsize以上の場合
if good:
# size未満の長さの断片をためていたリストからチャンクを作成してリストにする
chunks.extend(_merge_pieces(good, size, overlap))
# チャンクに変換したので、goodを空にする
good = []
if not rest:
# 残りの区切り文字がない場合は断片をそのままチャンクにする。
chunks.append(piece)
else:
# 残りの区切り文字を用いて断片を再帰的に分割する
# 返ってきたチャンクのリストを展開してchunksに追加
chunks.extend(split_recursive(piece, size, overlap, rest, keep_separator))
if good:
# すべての断片を処理した後で、チャンクに変換していないリスト(good)がある場合
# そのリストからチャンクを作成して展開し、チャンクリストに追加する
chunks.extend(_merge_pieces(good, size, overlap))
return chunks # 完成したチャンクを返す
# チャンギングを適用するテキスト文
text = """在宅勤務制度では、週3日まで在宅で働けます。利用するには前月末までに申請してください。
経費精算は毎月20日が締め切りです。専用システムから申請し、領収書の添付が必要です。承認は原則3営業日以内に行われます。"""
for i, c in enumerate(split_recursive(text, 40, keep_separator="end")):
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", " ", ""])が、単語をスペースで区切る英語を前提にしているため、分割がおかしくなっています。
日本語向けの場合には、引数separatorskeep_separator="end"を指定することで区切り文字をチャンクの末尾に残しています。
チャンクが句点で区切られるのか、より細かい読点などで区切られるのかは、チャンクサイズによって決まります。チャンクサイズが小さくなると、より細かい分割が行われるようになります。
オーバーラップ
文の途中が切れていなくても、文章をチャンクに分けると隣り合うチャンクの境界では、前後のつながりが切れてしまいます。たとえば、チャンクのはじまりが「その原理に基づくと、人口は減り続けることになる」と書いてあった場合、その原理が何を意味しているかをそのチャンクから知ることができません。
これを和らげるのがオーバーラップです。「隣り合うチャンクがお互いに重なり合うように区切る」ことで、境界部分の文脈を両方のチャンクに含める方法です。
RecursiveCharacterTextSplitterchunk_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
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)まとめ
チャンキングの基本と、チャンクサイズが検索に効くことを、実際に動かしながら確認しました。要点は次のとおりです。
- 文書を分割するのは、コンテキストウィンドウの制約に対応し、検索の粒度を上げるためです。
- 文や段落など自然な区切りを優先する構造を意識した分割にすると、文がチャンクサイズに収まる限り途中で切れにくくなり、各チャンクが意味のまとまりになりやすくなります。
- オーバーラップは、隣り合うチャンクを少し重ねて境界の文脈のつながりを保ちます。構造を意識した再帰的分割にオーバーラップを組み合わせるのが標準的です。
- チャンクサイズは検索精度と文脈の豊富さの間のトレードオフです。
- 日本語では、トークン密度・文の区切り・表記の揺れに注意します。
参考文献
- sentence-transformers公式ドキュメント
https://www.sbert.net/ - tiktoken(OpenAI、GitHub)
https://github.com/openai/tiktoken - LangChain Text splitters(RecursiveCharacterTextSplitter)
https://docs.langchain.com/oss/python/integrations/splitters/recursive_text_splitter