RAGを実装するときに迷いやすいのが、ドキュメントを何トークンごとに分割するかという問題です。
チャンクを小さくすれば、ユーザーの質問に直接関係する文章を検索しやすくなります。しかし、説明の前提や主語、条件、例外規定が別のチャンクへ分かれると、取得した文章だけでは正しい回答を作れません。
反対に、チャンクを大きくすれば文脈を維持しやすくなりますが、一つのベクトルへ複数の話題が混ざります。検索したい一文の意味が、周囲の無関係な文章によって薄まる可能性があります。
結論からいうと、すべてのRAGに共通する正解のチャンクサイズはありません。
一般的な文章では、まず400~800トークン程度から開始し、オーバーラップをチャンクサイズの10~25%程度に設定すると調整しやすくなります。MicrosoftはAzure AI Searchの初期値として512トークンと25%、つまり128トークンのオーバーラップを案内しています。一方、OpenAIのVector Storeは標準で800トークン、400トークンのオーバーラップを使用します。公式サービス同士でも初期値が異なることからも、数値をそのまま正解として扱うべきではないことが分かります。
この記事では、RAGのチャンクサイズとオーバーラップを決める考え方を解説します。AIエージェントの設計全体はAIエージェント完全ガイド【TypeScript】、コーディングエージェント向けのRAG文脈設計はコーディングエージェントのRAGを支える文脈設計とはもあわせてご覧ください。
- RAGのチャンクとは
- まずは512トークン前後から試す
- チャンクが小さすぎると起きる問題
- チャンクが大きすぎると起きる問題
- 文字数ではなくトークン数で考える
- 固定長分割は実装しやすい
- 再帰的分割は汎用RAGの初期値に向いている
- トークン単位で分割するTypeScript実装
- 見出し単位の分割はマニュアルやブログに向いている
- コードは関数やクラス単位で分割する
- セマンティック分割は話題の変化を検出する
- 表は通常の文章と分けて処理する
- オーバーラップとは
- オーバーラップは10~20%から開始する
- 構造単位で分けられるならオーバーラップを減らせる
- 文書タイプ別の初期値
- 検索用チャンクと生成用コンテキストを分ける
- チャンクサイズだけでなく検索件数も調整する
- 隣接チャンクを無条件に取得しない
- OpenAI Vector Storeでチャンクサイズを変更する
- 正解は検索評価で決める
- Recall@kとMRRを計算する
- チャンクIDではなく文書IDでも評価する
- 平均チャンクサイズだけを見ない
- 重複率も記録する
- 検索結果の重複を除去する
- チャンク変更時はEmbeddingを作り直す
- よくある失敗は最初から一つの値に決めること
- RAGのチャンクサイズに関するよくある質問
- まとめ
RAGのチャンクとは
RAGでは、PDFやWebページ、社内マニュアルなどのドキュメントを小さな単位へ分割し、それぞれをEmbeddingモデルでベクトルへ変換します。
ユーザーから質問を受け取ると、質問もベクトルへ変換し、意味が近いチャンクをベクトルデータベースから検索します。取得したチャンクをLLMのコンテキストへ追加することで、モデルが外部資料を根拠に回答できるようになります。
ドキュメント全体を一つのベクトルにすると、複数の話題が混ざり、特定の質問との類似度を正しく表現しにくくなります。一方で、単語や一文ごとに細かく分けすぎると、回答に必要な前後関係が失われます。
チャンク分割は、検索しやすさと文脈の保持を両立させるための処理です。
Microsoftはチャンク戦略の変更が、インデックス作成や検索、後続処理全体へ大きく影響する半永続的な設計判断だと説明しています。運用開始後に変更する場合は、既存データの再分割、再Embedding、再インデックスが必要になります。
まずは512トークン前後から試す
通常のマニュアル、ブログ記事、社内資料などを対象にする場合は、最初の比較対象として512トークンを設定すると調整しやすくなります。
ただし、512トークンが最適値という意味ではありません。
比較テストでは、256トークン、512トークン、800トークン程度の三つを用意し、同じ質問セットで検索結果を測定します。
短いFAQが中心なら、256トークンでも十分な場合があります。背景、条件、手順、注意事項を一緒に取得する必要があるマニュアルでは、512~800トークンのほうが回答に必要な情報を一つのチャンクへ残しやすくなります。
OpenAI Vector Storeの自動分割は800トークンと400トークンのオーバーラップを使用しますが、カスタム設定ではチャンクサイズを100~4096トークンの範囲で変更できます。オーバーラップはチャンクサイズの半分以下にする必要があります。
OpenAIの標準値はFile Searchを簡単に利用するための汎用設定です。自前のRAGで文書構造や検索質問が分かっている場合は、標準値に固定せず比較する価値があります。
チャンクが小さすぎると起きる問題
チャンクを小さくすると、一つのチャンクに含まれる話題が限定されるため、検索結果の精度が上がる場合があります。
たとえば、返品期限を質問されたときに、返品期限だけを含む短いチャンクを検索できれば、無関係な配送方法や支払い方法をLLMへ送らずに済みます。
しかし、短くしすぎると回答に必要な条件が分離されます。
次の文章を考えます。
商品到着後30日以内であれば返品できます。 ただし、開封済みのソフトウェアとダウンロード商品は返品対象外です。 初期不良の場合は開封後も交換を受け付けます。
一文単位で分割すると、「商品到着後30日以内であれば返品できます」というチャンクだけが検索される可能性があります。
その結果、LLMは開封済みソフトウェアの例外や、初期不良時の交換条件を知らないまま回答します。
短いチャンクを採用する場合は、検索件数を増やす、隣接チャンクも取得する、見出しや親セクションを追加するといった対策が必要です。
チャンクが大きすぎると起きる問題
チャンクを大きくすると、一つの検索結果に十分な文脈を含めやすくなります。
しかし、返品、配送、決済、会員登録について書かれたページ全体を一つのチャンクにすると、一つのベクトルが複数の意味を表現することになります。
ユーザーが「返品期限は何日ですか」と検索しても、チャンク全体では配送や決済に関する単語の割合が大きくなり、返品に特化したチャンクより検索順位が下がる可能性があります。
大きなチャンクを複数取得すると、LLMへ渡す入力トークンも増加します。検索結果の一部しか回答に使わない場合でも、取得したチャンク全体がプロンプトへ追加されるため、料金と応答時間へ影響します。
検索では関連する候補を広く取得するほど再現率を上げやすくなりますが、無関係なチャンクまでLLMへ渡すとコンテキストがノイズで薄まります。Microsoftも、検索で候補を広げたあとにリランキングで精度を高める設計を案内しています。
文字数ではなくトークン数で考える
チャンクサイズは、文字数よりトークン数で管理するほうがモデルの制限へ合わせやすくなります。
日本語、英語、コード、JSONでは、一文字あたりのトークン数が同じではありません。2,000文字の日本語文章と2,000文字の英語文章を、同じトークン数として扱うことはできません。
EmbeddingモデルやLLMには入力できるトークン数の上限があります。分割時のトークン数は、使用するモデルと互換性のあるTokenizerで測定する必要があります。
LangChainの公式ドキュメントでも、トークン単位で分割するときは、対象モデルが使用するTokenizerと同じものを利用するよう案内しています。JavaScriptではTokenTextSplitterとjs-tiktokenを使った分割が用意されています。
一方、文字数ベースの分割には、処理が軽く、Embeddingモデルを変更しても同じチャンクを再現しやすい利点があります。
最初は段落や見出しを維持する文字数ベースの分割を使い、保存前にトークン数を確認して上限を超えたチャンクだけ再分割する方法もあります。
固定長分割は実装しやすい
固定長分割は、一定の文字数またはトークン数ごとに文章を区切る方法です。
実装が単純で、ドキュメントの形式が混在していても同じ処理を適用できます。メール、レビュー、自由記述、メモなど、明確な見出し構造を持たないデータにも向いています。
一方、指定した長さへ到達した位置で機械的に分割すると、文や段落の途中で切れる可能性があります。
Microsoftは、固定長分割を利用する場合でも、文字数より言語上の意味を反映しやすいトークン単位を使い、実際のドキュメントで分割結果を可視化して比較するよう案内しています。
固定長分割をそのまま使うより、段落、改行、文末、空白の順で分割位置を探す再帰的分割のほうが、文章のまとまりを維持しやすくなります。
再帰的分割は汎用RAGの初期値に向いている
LangChainのRecursiveCharacterTextSplitterは、最初に段落単位で分割を試し、サイズを超える場合は改行、文、単語など、より小さな単位へ順番に分割します。
LangChainは、一般的な用途では最初にRecursiveCharacterTextSplitterを試すことを推奨しています。固定サイズを守りながら、可能な限り段落や文を維持できるためです。
Node.jsとTypeScriptでは、次のように利用できます。
npm install @langchain/textsplitters @langchain/core
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 2_000,
chunkOverlap: 200,
separators: [
"\n\n",
"\n",
"。",
"!",
"?",
"、",
" ",
"",
],
});
const chunks = await splitter.createDocuments([
{
pageContent: documentText,
metadata: {
documentId: "manual-001",
source: "product-manual",
version: "2026-08",
},
},
]);
for (const [index, chunk] of chunks.entries()) {
console.log({
index,
length: chunk.pageContent.length,
preview: chunk.pageContent.slice(0, 80),
metadata: chunk.metadata,
});
}
このコードのchunkSizeとchunkOverlapは文字数です。
LangChainの標準的な再帰分割は、段落を表す二重改行、改行、空白などを順番に使用します。日本語では単語間に空白がないため、。、、、!、?などを区切り文字へ追加すると、文字の途中で分割される状況を減らせます。LangChainの公式ドキュメントでも、日本語、中国語、タイ語などを扱う場合は、句点や読点などの区切り文字を追加する方法が案内されています。
文字数ベースで分割する場合でも、Embeddingを作成する前に各チャンクのトークン数を測定し、モデルの上限を超えていないことを確認します。
トークン単位で分割するTypeScript実装
トークン数を直接基準にしたい場合は、LangChainのTokenTextSplitterを利用できます。
import { TokenTextSplitter } from "@langchain/textsplitters";
const splitter = new TokenTextSplitter({
encodingName: "cl100k_base",
chunkSize: 512,
chunkOverlap: 64,
});
const chunks = await splitter.createDocuments([
{
pageContent: documentText,
metadata: {
documentId: "manual-001",
version: "2026-08",
},
},
]);
for (const [index, chunk] of chunks.entries()) {
console.log({
index,
text: chunk.pageContent,
metadata: chunk.metadata,
});
}
この例では、512トークンのチャンクに64トークンのオーバーラップを設定しています。
ただし、encodingNameは使用するEmbeddingモデルと互換性のあるものを選ぶ必要があります。Tokenizerが異なると、分割時に数えたトークン数と、APIが実際に処理するトークン数に差が出ます。
トークン単位の固定分割だけでは、見出しや段落の途中で切れる可能性があります。
実務では、最初に見出しや段落で分割し、上限を超えたセクションだけをトークン単位で再分割する二段階方式が扱いやすくなります。
見出し単位の分割はマニュアルやブログに向いている
Markdown、HTML、社内Wiki、製品マニュアルなど、見出し構造がある文章は、その構造を利用して分割します。
たとえば、「返品方法」という見出しの本文と、「支払い方法」という見出しの本文を同じチャンクへ入れる必要はありません。
見出し単位で分割すると、各チャンクの話題が明確になり、検索結果へ見出しを表示できる利点もあります。
ただし、見出しだけで分割すると、非常に長い章と一行しかない章が混在します。
見出しでセクションを分けたあと、長いセクションを512トークン前後で再分割し、短すぎるセクションは同じ親見出し内で結合する方法が有効です。
Unstructuredのby_title方式も、見出しを検出した時点で前のチャンクを閉じ、新しいセクションを開始します。通常のチャンクでは意味のまとまりを維持し、指定サイズを超える要素だけをテキスト分割する設計です。
検索用のチャンクには、本文だけでなく見出し階層も追加します。
製品A トラブルシューティング 電源が入らない場合 電源ケーブルが奥まで接続されていることを確認してください。
本文に「製品A」という名前が含まれていなくても、見出しを先頭へ追加すれば、製品名を含む質問との類似度を高めやすくなります。
Azure AI Searchも、大きな文書を分割するときに、文書タイトルを途中のチャンクへ追加して文脈の欠落を防ぐ構成を例示しています。
コードは関数やクラス単位で分割する
ソースコードを一定の文字数だけで分割すると、関数の宣言と本体、クラス名とメソッド、エラー処理と呼び出し元が別のチャンクへ分かれる可能性があります。
コード検索では、関数、クラス、メソッド、モジュールなど、構文上の単位を優先します。
LangChainのRecursiveCharacterTextSplitter.fromLanguage()には、JavaScript、TypeScript、Python、Java、Go、Rust、HTML、Markdownなど、プログラミング言語ごとの区切り規則が用意されています。
TypeScriptコードを分割する例は次のとおりです。
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const splitter = RecursiveCharacterTextSplitter.fromLanguage(
"ts",
{
chunkSize: 1_500,
chunkOverlap: 100,
},
);
const chunks = await splitter.createDocuments([
{
pageContent: sourceCode,
metadata: {
repository: "example-app",
filePath: "src/services/payment.ts",
language: "typescript",
commit: "abc123",
},
},
]);
for (const chunk of chunks) {
console.log(chunk.pageContent);
}
可能であれば、ASTを解析して関数やクラス単位で分割し、ファイルパス、シンボル名、開始行、終了行、コミットIDをメタデータとして保存します。
短い関数を複数まとめる場合も、異なるファイルやクラスのコードを一つのチャンクへ混ぜないほうが検索結果を説明しやすくなります。
セマンティック分割は話題の変化を検出する
セマンティック分割は、文字数や見出しではなく、文章の意味が変化した位置をEmbeddingで検出する方法です。
隣接する文のEmbeddingを比較し、類似度が大きく低下した場所をチャンク境界として使用します。
LlamaIndexのSemanticSplitterNodeParserは、隣接する文グループのEmbeddingからコサイン距離を計算し、指定したパーセンタイルを超える変化を境界としてチャンクを作成します。しきい値を小さくすると、より多くのチャンクが生成されます。
セマンティック分割は、見出しのない議事録や長文、複数の話題が連続するレポートに向いています。
一方、分割処理自体にEmbeddingが必要となるため、固定長分割より取り込み時間と料金が増えます。チャンクサイズも一定にならず、極端に短いチャンクや長いチャンクが生成される可能性があります。
Microsoftも、セマンティック分割は意味の近い内容をまとめられる一方、複雑なロジックと高い処理コストを必要とする方式として分類しています。
最初からすべてのデータへ適用するのではなく、固定長や見出し分割で精度が出ない文書だけを対象に比較する方法が現実的です。
表は通常の文章と分けて処理する
PDF内の表を段落と同じ方法で分割すると、列名と値の対応が壊れることがあります。
たとえば、料金表のヘッダーが一つ前のチャンクにあり、価格だけが次のチャンクへ入ると、「9,800円」がどのプランの料金なのか分かりません。
小さな表は一つのチャンクとして維持します。
大きな表を分割する場合は、各チャンクへ列名、表のタイトル、単位を繰り返し追加します。行ごとに分ける場合も、ヘッダーを省略しないことが重要です。
Unstructuredでは、表を通常のテキスト要素と結合せず、独立した要素として扱います。大きすぎる表だけを複数のTableChunkへ分割する設計になっています。
PDFのページ単位で機械的に分割すると、ページをまたぐ表や文章が途中で切れるため、レイアウト解析後の見出し、段落、表といった要素を利用するほうが安全です。
オーバーラップとは
オーバーラップは、一つ前のチャンクの末尾を、次のチャンクの先頭へ重複して含める仕組みです。
512トークンのチャンクに64トークンのオーバーラップを設定した場合、次のチャンクは前のチャンクの末尾64トークンを引き継ぎます。
境界をまたぐ説明が両方のチャンクに含まれるため、必要な情報が分割位置で失われる問題を抑えられます。
ただし、オーバーラップを増やすほど、保存するベクトル数、Embedding対象のトークン数、検索結果の重複が増加します。
同じ文章を含む隣接チャンクが上位に並び、検索結果の枠を消費する場合もあります。
オーバーラップは10~20%から開始する
独自のRAGでは、まずチャンクサイズの10~20%程度から試すと、重複を抑えながら境界の情報を維持しやすくなります。
512トークンなら、50~100トークン程度が初期値になります。
会話、物語、議事録のように前後の文脈が重要な文章では、20~25%まで増やす価値があります。FAQや見出しごとに内容が独立している文書では、オーバーラップを0~10%に減らせる場合があります。
Microsoftは固定長分割の初期設定として512トークンと128トークン、つまり25%のオーバーラップを案内し、構造化されたデータでは少なめ、会話や物語では多めに調整するよう説明しています。
OpenAI Vector Storeの標準設定は800トークンに400トークンのオーバーラップで、重複率は50%です。検索対象の文脈を広く残す設定ですが、自前のベクトルデータベースで同じ値を使うと、インデックス容量や検索結果の重複が大きくなる場合があります。
オーバーラップの割合だけでなく、実際にどの文章が重複しているかを確認する必要があります。
構造単位で分けられるならオーバーラップを減らせる
見出し、段落、関数、表など、意味のある境界で分割できている場合は、必ずしも大きなオーバーラップは必要ありません。
Unstructuredは、通常のチャンクを意味のある文書要素から作成し、大きすぎる要素を分割するときだけ標準でオーバーラップを使用します。正常な意味境界を持つチャンクへも重複を適用すると、内容を汚す可能性があると説明しています。
たとえば、「返品条件」と「支払い方法」が別の見出しで分かれている場合、返品条件の末尾を支払い方法のチャンクへ重複させる必要はありません。
一方、長い一つの段落を固定長で分割する場合は、前後関係を残すためにオーバーラップが有効です。
すべての境界へ一律の重複を設定するより、意味境界で分割できなかった場所だけへオーバーラップを適用するほうが、インデックスを小さく保てます。
文書タイプ別の初期値
一般的な解説記事や社内マニュアルでは、400~800トークン、オーバーラップ10~20%から始めます。見出し単位で先に分割し、長いセクションだけを固定長で再分割します。
一問一答形式のFAQでは、一つの質問と回答を一つのチャンクとして扱います。回答が短い場合は200~400トークン程度に収まり、オーバーラップは不要なことがあります。
規約、契約書、法務文書では、条、項、号などの構造を優先します。関連する定義や例外が離れている場合があるため、チャンクを大きくするだけでなく、条番号、文書名、改訂日をメタデータへ保存します。
会話ログや議事録では、発言者と話題の切り替わりを境界として使います。前の発言を参照する表現が多いため、通常のFAQよりオーバーラップを多めにするか、直前の発言をメタデータとして追加します。
ソースコードでは、関数やクラス単位を優先し、200~600トークン程度から比較します。短い関数は同じクラスやモジュール内で結合し、長い関数だけを内部のブロック単位で再分割します。
表では、行数やトークン数より列名との対応を優先します。大きな表を分ける場合は、各チャンクへ同じヘッダーを付けます。
これらは最終的な正解ではなく、評価を始めるための初期値です。
検索用チャンクと生成用コンテキストを分ける
検索精度を上げるには短いチャンクが有利でも、回答生成にはより広い文脈が必要な場合があります。
この問題は、検索用の小さなチャンクと、生成用の大きな親セクションを分けることで解決できます。
取り込み時に、見出し単位の親チャンクと、その内部を分割した子チャンクを保存します。検索では子チャンクのEmbeddingを使用し、ヒットした子チャンクに対応する親チャンクをLLMへ渡します。
type ParentChunk = {
id: string;
documentId: string;
headingPath: string[];
text: string;
};
type ChildChunk = {
id: string;
parentId: string;
documentId: string;
chunkIndex: number;
text: string;
};
たとえば、検索用の子チャンクを300トークン、生成用の親チャンクを1,200トークン程度にできます。
検索では細かな質問へ反応しやすくしながら、LLMには条件や例外を含む広い文章を渡せます。
ただし、複数の子チャンクが同じ親にヒットした場合は、親チャンクを重複して送らないようIDでまとめます。
チャンクサイズだけでなく検索件数も調整する
チャンクサイズとtop_kは一緒に調整する必要があります。
256トークンのチャンクを3件取得すると、最大で約768トークンの本文をLLMへ渡します。800トークンのチャンクを3件取得すると、最大で約2,400トークンになります。
チャンクサイズだけを大きくし、検索件数を同じにすると、LLMへ渡すコンテキスト量が大幅に増えます。
反対に、チャンクを小さくしたのに検索件数を増やさないと、回答に必要な複数の断片を取得できない可能性があります。
比較テストでは、チャンクサイズごとにコンテキストの総トークン数が極端に変わらないよう調整します。
たとえば、256トークンではtop_kを8、512トークンでは4、1024トークンでは2にする方法があります。
ただし、実際のチャンク長は上限より短くなるため、件数だけではなく、取得後の総トークン数を計測するほうが正確です。
隣接チャンクを無条件に取得しない
ヒットしたチャンクの前後を必ず取得すれば、境界で失われた文脈を補えます。
しかし、検索結果が5件あり、それぞれの前後を取得すると、最大15件のチャンクをLLMへ渡すことになります。
同じ文書の近い位置に複数の検索結果がある場合は、範囲を統合します。
また、隣接チャンクを取得する前に、親セクション、見出し、チャンク番号を確認します。別の見出しへ移ったチャンクまで自動的に追加すると、無関係な文脈が混ざる可能性があります。
隣接取得は、オーバーラップを大きくする代わりとしても利用できます。
通常はオーバーラップを小さく保ち、回答に広い文脈が必要な質問だけ前後のチャンクを取得する設計です。
OpenAI Vector Storeでチャンクサイズを変更する
OpenAIのVector Storeへファイルを追加するときは、chunking_strategyで固定チャンクサイズを指定できます。
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const vectorStoreId = process.env.OPENAI_VECTOR_STORE_ID;
const fileId = process.env.OPENAI_FILE_ID;
if (!vectorStoreId || !fileId) {
throw new Error(
"OPENAI_VECTOR_STORE_IDとOPENAI_FILE_IDを設定してください。",
);
}
const vectorStoreFile =
await openai.vectorStores.files.create(
vectorStoreId,
{
file_id: fileId,
chunking_strategy: {
type: "static",
static: {
max_chunk_size_tokens: 512,
chunk_overlap_tokens: 64,
},
},
},
);
console.log({
id: vectorStoreFile.id,
status: vectorStoreFile.status,
});
この例では、512トークンと64トークンのオーバーラップを設定しています。
OpenAI Vector Storeでは、チャンクサイズを100~4096トークンで指定でき、オーバーラップはチャンクサイズの半分以下にする必要があります。
ファイルを登録したあとにチャンク戦略を変更したい場合は、同じファイルを新しい設定で再取り込みし、検索評価をやり直します。
正解は検索評価で決める
チャンクサイズを決めるときに、数件の質問を画面から試すだけでは不十分です。
実運用で想定される質問と、その回答根拠を含む文書またはチャンクを用意します。
少なくとも、具体的な事実を尋ねる質問、複数の条件をまとめる質問、例外規定を必要とする質問、文書内に答えがない質問を含めます。
比較する設定は、一度に一つだけ変えます。
Embeddingモデル、検索方式、リランキング、プロンプトまで同時に変更すると、チャンクサイズによって改善したのか判断できません。
MicrosoftはRAGの評価において、検索精度、回答品質、料金、レイテンシを分けて測定することを推奨しています。検索では、取得チャンクのうち何件が関連していたかを示すPrecisionと、正解文書のうち何件を取得できたかを示すRecallが基本指標になります。
Recall@kとMRRを計算する
正解チャンクまたは正解文書のIDが分かっている場合は、Recall@kやMRRをコードで計算できます。
type EvaluationCase = {
query: string;
relevantDocumentIds: string[];
retrievedDocumentIds: string[];
};
type EvaluationMetrics = {
hitRate: number;
recallAtK: number;
meanReciprocalRank: number;
};
function evaluateRetrieval(
cases: readonly EvaluationCase[],
k: number,
): EvaluationMetrics {
if (!Number.isInteger(k) || k < 1) {
throw new Error("kには1以上の整数を指定してください。");
}
if (cases.length === 0) {
return {
hitRate: 0,
recallAtK: 0,
meanReciprocalRank: 0,
};
}
let hitCount = 0;
let recallTotal = 0;
let reciprocalRankTotal = 0;
for (const evaluationCase of cases) {
const relevantIds = new Set(
evaluationCase.relevantDocumentIds,
);
const retrievedIds =
evaluationCase.retrievedDocumentIds.slice(0, k);
const matchedIds = new Set(
retrievedIds.filter((id) => relevantIds.has(id)),
);
if (matchedIds.size > 0) {
hitCount += 1;
}
if (relevantIds.size > 0) {
recallTotal += matchedIds.size / relevantIds.size;
}
const firstRelevantIndex = retrievedIds.findIndex(
(id) => relevantIds.has(id),
);
if (firstRelevantIndex >= 0) {
reciprocalRankTotal +=
1 / (firstRelevantIndex + 1);
}
}
return {
hitRate: hitCount / cases.length,
recallAtK: recallTotal / cases.length,
meanReciprocalRank:
reciprocalRankTotal / cases.length,
};
}
hitRateは、上位k件に一つでも正解が含まれた質問の割合です。
recallAtKは、必要な正解文書のうち、上位k件で取得できた割合です。
meanReciprocalRankは、最初の正解が上位にあるほど高くなります。
同じ評価データを使い、256トークン、512トークン、800トークンの設定を比較します。
const configurations = [
{
name: "256-32",
chunkSize: 256,
chunkOverlap: 32,
},
{
name: "512-64",
chunkSize: 512,
chunkOverlap: 64,
},
{
name: "800-160",
chunkSize: 800,
chunkOverlap: 160,
},
] as const;
検索指標だけでなく、最終回答の正確性、根拠との一致、入力トークン数、検索時間も記録します。
検索では正解を取得できていても、LLMのプロンプトや回答生成に問題があれば、最終回答は間違います。反対に、偶然モデルの事前知識で正解しても、検索結果が間違っている場合があります。検索と回答を分けて評価することが重要です。
チャンクIDではなく文書IDでも評価する
チャンクサイズを変更すると、チャンクの数、境界、IDが変化します。
そのため、設定Aの正解チャンクIDを、設定Bへそのまま利用できません。
評価データには、正解の文書ID、ページ番号、見出し、原文の範囲などを保存します。
検索されたチャンクが、その正解範囲と重なっているかを判定すれば、異なるチャンク設定を公平に比較できます。
type TextRange = {
documentId: string;
startOffset: number;
endOffset: number;
};
function rangesOverlap(
first: TextRange,
second: TextRange,
): boolean {
if (first.documentId !== second.documentId) {
return false;
}
return (
first.startOffset < second.endOffset &&
second.startOffset < first.endOffset
);
}
ページ番号だけでは、同じページ内に複数の話題がある場合に評価が粗くなります。
可能であれば、元文書内の開始位置と終了位置をチャンクのメタデータへ保存します。
平均チャンクサイズだけを見ない
512トークンを指定しても、すべてのチャンクが512トークンになるわけではありません。
見出しや段落を維持すると、100トークン未満の短いチャンクが大量に生まれる場合があります。反対に、表や長いコードブロックの処理に失敗すると、上限を超えるチャンクが残ることがあります。
取り込み後には、最小値、中央値、95パーセンタイル、最大値を確認します。
function percentile(
values: readonly number[],
percentage: number,
): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].sort(
(a, b) => a - b,
);
const index = Math.min(
sorted.length - 1,
Math.max(
0,
Math.ceil(
(percentage / 100) * sorted.length,
) - 1,
),
);
return sorted[index]!;
}
function summarizeChunkSizes(
tokenCounts: readonly number[],
) {
if (tokenCounts.length === 0) {
return {
count: 0,
min: 0,
median: 0,
p95: 0,
max: 0,
average: 0,
};
}
const total = tokenCounts.reduce(
(sum, value) => sum + value,
0,
);
return {
count: tokenCounts.length,
min: Math.min(...tokenCounts),
median: percentile(tokenCounts, 50),
p95: percentile(tokenCounts, 95),
max: Math.max(...tokenCounts),
average: total / tokenCounts.length,
};
}
短すぎるチャンクが多い場合は、同じ見出し内の隣接要素を結合します。
長すぎるチャンクが残っている場合は、表、コードブロック、改行のない文章など、例外となっているデータを確認します。
重複率も記録する
オーバーラップを増やすと、元文書よりインデックス対象の総トークン数が増えます。
たとえば、元文書が100万トークンでも、50%のオーバーラップで分割すれば、Embeddingする総トークン数は100万トークンを大きく上回ります。
重複率は、チャンク化後の総トークン数と元文書のトークン数から確認できます。
function calculateDuplicationRatio(
sourceTokens: number,
chunkTokens: readonly number[],
): number {
if (sourceTokens <= 0) {
return 0;
}
const indexedTokens = chunkTokens.reduce(
(sum, value) => sum + value,
0,
);
return indexedTokens / sourceTokens;
}
結果が1.2なら、元文書に対して約20%多いトークンをインデックスしています。
検索精度がほとんど変わらないのに重複率だけが増えている場合は、オーバーラップを減らす余地があります。
検索結果の重複を除去する
オーバーラップの大きいチャンクでは、ほぼ同じ文章を含む隣接チャンクが上位へ並ぶことがあります。
そのままtop_k件をLLMへ渡すと、同じ内容でコンテキストの多くを消費します。
同じ文書内で範囲が大きく重なるチャンクを統合するか、よりスコアの高い一方だけを残します。
同じ親セクションに属するチャンクは、親IDでまとめてからLLMへ送る方法もあります。
ただし、単純な文字列一致だけで除去すると、規約などで意図的に繰り返されている重要な文章を消す可能性があります。
文書ID、親セクションID、開始位置、終了位置を利用して、隣接チャンクだけを対象に判定します。
チャンク変更時はEmbeddingを作り直す
チャンクサイズや分割境界を変更すると、Embeddingの入力文章そのものが変わります。
既存ベクトルのメタデータだけを書き換えても、新しいチャンクを表すベクトルにはなりません。
新しいチャンク戦略を試すときは、別のインデックスまたは名前空間へデータを登録し、旧設定と並行して評価します。
評価が完了してから本番の参照先を切り替えれば、問題があったときに旧インデックスへ戻せます。
チャンクには、分割戦略のバージョンも保存します。
const metadata = {
documentId: "manual-001",
documentVersion: "2026-08",
chunkingVersion: "recursive-512-64-v2",
embeddingModel: process.env.EMBEDDING_MODEL,
};
分割ルール、Embeddingモデル、前処理を変更した場合は、別のバージョンとして扱います。
よくある失敗は最初から一つの値に決めること
RAGのチャンクサイズについて検索すると、500、512、800、1000など、さまざまな数値が見つかります。
これらは、それぞれ異なる文書、Embeddingモデル、検索方式、top_k、LLM、評価方法で得られた設定です。
別のシステムで良かった数値をそのまま採用しても、自分のデータで同じ結果になるとは限りません。
公式サービスの初期値も、汎用的に動かすための出発点です。Azure AI Searchは512トークンと25%、OpenAI Vector Storeは800トークンと50%という異なる値を採用しています。
重要なのは、初期値を早く決め、実際の質問セットで比較できる仕組みを先に作ることです。
RAGのチャンクサイズに関するよくある質問
Q1000文字と1000トークンは同じですか?
A同じではありません。トークン数は言語、記号、コード、使用するTokenizerによって変化します。文字数ベースのRecursiveCharacterTextSplitterで1000を指定しても、1000トークンに分割されるわけではありません。モデルの入力制限へ正確に合わせたい場合は、そのモデルに対応したTokenizerで数えてください。LangChainも、トークン分割では対象モデルと同じTokenizerを使用するよう案内しています。
Qオーバーラップは多いほど精度が上がりますか?
A必ずしも上がりません。オーバーラップを増やすと境界付近の文脈を維持しやすくなりますが、Embedding料金、インデックス容量、検索結果の重複も増えます。意味のある見出しや段落で分割できている場合は、オーバーラップを減らしても精度を維持できる可能性があります。
QチャンクサイズはEmbeddingモデルの上限まで大きくしてよいですか?
A上限まで入力できることと、検索に適したサイズであることは別です。長い文章をEmbeddingできても、複数の話題が一つのベクトルへ混ざれば、細かな質問への検索精度が下がる可能性があります。入力上限は超えてはいけない最大値であり、推奨チャンクサイズではありません。
QPDFはページ単位で分割すればよいですか?
Aページ単位が適切な文書もありますが、一般的な正解ではありません。文章や表がページをまたぐ場合、ページ境界で意味が切れます。反対に、一つのページに複数の見出しや話題がある場合は、ページ単位ではチャンクが大きすぎます。PDFでは、レイアウト解析によって見出し、段落、表を抽出してから分割するほうが、意味のまとまりを維持しやすくなります。
まとめ
RAGのチャンクサイズに、すべてのシステムで使える一つの正解はありません。
通常の文章では、まず400~800トークン程度から開始し、256、512、800トークンなど複数の設定を同じ質問セットで比較します。
オーバーラップは10~20%程度から開始し、会話や長い説明では増やし、FAQや見出し単位で独立した文書では減らします。
固定長だけで機械的に切るのではなく、見出し、段落、文、関数、クラス、表など、文書が本来持つ構造を優先することが重要です。一般的な文章では再帰的分割、Markdownやマニュアルでは見出し分割、ソースコードでは言語別またはAST分割、話題が混在する長文ではセマンティック分割を比較します。
検索には小さいチャンク、回答生成には大きな親セクションを使う方法もあります。
最終的な数値は、Recall、Precision、MRR、回答の正確性、入力トークン数、レイテンシ、インデックス容量を測定して決めます。
公式サービスの標準値は便利な出発点ですが、自分の文書とユーザーの質問に対して評価しない限り、最適なチャンクサイズとは判断できません。AIエージェントの設計全体はAIエージェント完全ガイド【TypeScript】、コーディングエージェント向けのRAG文脈設計はコーディングエージェントのRAGを支える文脈設計とはもあわせてご覧ください。

