RAGにおけるチャンキング戦略の比較:代替案、トレードオフ、および例

RAGにおけるチャンキング戦略の比較

目次

Chunking は、Retrieval ‑ Augmented Generation (RAG) において 最も過小評価されている ハイパラメータです。
静かに、LLM が「何を見ているか」を決定し、インジェストのコストをどのくらい高めるか、そしてLLMのコンテキストウィンドウがどのくらい使われるかを決めています。

本記事では、chunking をエンジニアリングの最適化問題として扱います:目標を定義し、戦略を選択し、測定し、反復します。

RAG アーキテクチャに初めて触れる場合は、まず以下の主要な記事から始めることをお勧めします。
Retrieval-Augmented Generation (RAG) Tutorial: Architecture, Implementation, and Production Guide

table 上の colour tetris

TL;DR (Executive summary)

RAG システムは チャンク を取得しますが、ドキュメントではありません。そのため、チャンクングは、取得の単位、埋め込みコストの単位、示したり引用したりできる証拠の単位を定義します。RAG の元の定義では、取得は生成にパッセージを供給します;パッセージの境界は実質的にチャンクの境界です。

良いチャンクング戦略は、取得品質(証拠の再現率/精度)、連続性(チャンクが解釈可能である)、コスト(埋め込み、ストレージ、クエリのレイテンシー)の間でパレートフロンティアを追求します。グローバルに最適なチャンクサイズや方法は存在せず、生産システムでは戦略を混在させることが一般的です(例:PDF用の構造を意識したチャンクング+文章用のセマンティックを意識した分割+コード用のASTチャンクング)。

「ドキュメンテーションQA」や内部の知識ベースの多くでは、構造を尊重する再帰分割器 が安全なデフォルトです。適度な重複(境界損失を減らすため)があり、メタデータフィルタリングとオプションの再ランキングをサポートする ベクトルストア を使用するのが一般的です。LangChainの RecursiveCharacterTextSplitter はこの階層セパレータのアイデアの一般的な実装です;重複は、関連する文脈が境界で切られることによる情報損失を緩和するために存在します。

ドキュメントが強い構造を持っている場合(見出し、表、リスト、キャプション付きのPDF)では、要素ベース/構造を意識したチャンクング はトークンカウント切り分けよりも優れており、チャンク数も少なくなります。2024年のSECの文書に関する研究では、要素タイプベースのチャンクングはRAGの結果を改善し、構造を無視する方法と比べてチャンク数(およびベクトル数)を約半分に減少させたことが報告されています。これは、インデックスコストを削減し、クエリのレイテンシーを改善する可能性があります。

もし初期の計算リソースを確保できるなら、セマンティックチャンクング(埋め込み類似度に基づいてトピックの変化で分割)は、物語的なテキストや混合トピックのページの検索精度を大幅に向上させることができます。TextTilingなどの古いトピックセグメンテーションアルゴリズムは、語彙/セマンティックの変化が強い境界候補であるという一般的な原理を示しています。

非常に長い、内部的に相互参照する資料(ポリシー、RFC、標準、大規模なマニュアル)では、階層的チャンクング+階層的取得/マージ(親/子ノード)は、要求に応じてより多くの連続文脈を復元できます。LlamaIndexの階層ノードパーサーは粗から細へのチャンク階層を生成し、AutoMergingRetrieverは十分な関連子ノードが取得された場合に取得時に子ノードを親ノードにマージできます。

チャンクングの目標とトレードオフ

チャンクングは単に「テキストを埋め込みモデルに合うように分割する」だけでなく、いくつかの下流および運用的な行動を制御します。

取得の粒度と取得のノイズのトレードオフ。より小さなチャンクは、答えを含む正確な文が取得される可能性を高めます(固定top-kでの再現率が高くなる可能性)。しかし、ベクトル数が増加し、インデックスサイズが増えることがあり、時にはセマンティックに似ているが実際には証拠ではない「近いマッチ」が表面化することもあります(精度が低下する)。

文脈の連続性と境界損失のトレードオフ。連続したチャンクは、LLMが正しく推論し、定義や制約、前提条件などの完全なローカル文脈を提供することで、幻覚を減らすのに役立ちます。重複は境界損失を減らしますが、重複したテキストを作成し、重複を削除/マージしない場合、冗長な取得結果やプロンプト長が増加する可能性があります。

埋め込みおよびインデクシングコスト。埋め込みコストは通常トークン数に比例し、インジェスト時間はチャンク数に比例します(ベクトルDBの書き込みオーバーヘッドも含む)。OpenAIの埋め込みでは、各入力の最大トークン数制限(すべての埋め込みモデルで8192トークン)と、各リクエストごとの入力の合計トークン数の最大制限(300,000トークン)があります。大規模なコーパスでは、バッチAPIは非同期で24時間の処理時間をかけてコストを約50%削減できるため、バックフィルや定期的な再インデックスに有用です。

ベクトルインデックスサイズ、RAM、およびレイテンシー。チャンク数が多いとベクトル数が多くなり、メモリが増加し、クエリが遅くなる可能性があります(インデックスの種類によります)。FAISSでは、検索時間、検索品質、およびインデックスされたベクトルごとのメモリをトレードオフとして明示的に設計しており、GPU実装も提供して高速な正確検索と近似検索を可能にしています。

下流LLMプロンプト長/コンテキストウィンドウ使用。リトリーバーの出力はプロンプト予算になります。一貫して「ちょうど十分な」文脈を取得するチャンクング戦略は、答えの品質を向上させ、コストを削減できます。一方、重複や非常に大きなチャンクはプロンプト長を膨張させます。実際には、(チャンクサイズ、重複、top-k、再ランキング/マージ)を一緒に調整することが一般的です。

更新/インジェストコストと重複削除。チャンクングは、データを更新するコストに影響を与えます。小さなチャンクは部分的な更新を安くする(変更されたセクションだけを再埋め込み可能)が、重複または近似重複のチャンクが増えると重複削除が難しくなる可能性があります。

チャンクングがRAGワークフローに位置する場所

RAGフロー内のチャンクング

チャンクング戦略と代替案

以下は、現代のRAGで出会う主なチャンクングファミリです。実際には、多くの場合、構造優先のチャンクング(ドキュメント境界を尊重)とトークン予算の強制(埋め込みとプロンプト予算に合うようにチャンクを確保)の2つを組み合わせます。

固定サイズのチャンクング

何であるか。 文字またはトークンで等しいサイズのブロックにテキストを分割します。

なぜ存在するか。 簡単で、高速で、予測可能で、並列化しやすいです。また、フルドキュメントコンテキストがないストリーミングインジェストでは、最も簡単な戦略です。

どこで失敗するか。 境界(文、セクション、コードブロック)を無視するため、定義を破損したり、「質問/回答ペア」をチャンクにまたがって分割したりして、取得エラーを増やす可能性があります。

運用プロファイル。 最も低いインジェスト複雑性;予測可能なチャンク数;最も簡単なキャッシュ。ただし、境界損失を避けるために通常は重複(下記)が必要です。

重複チャンクング

何であるか。 連続するチャンクが固定された重複領域(例:チャンクサイズの10〜20%)を共有する戦略です。重複は多くのフレームワークで標準です。文脈が分割されるときに情報損失を減らすためです。

なぜ重要か。 重複は実質的に「柔らかい境界」です。境界をまたいでいる事実を取得させることができます。

コストと落とし穴。 埋め込むトークン数が増加します;インデックス内の重複テキストが増加します;重複したチャンクを取得するリスクが高まります(取得時に重複を削除しない限り、ほぼ同じチャンクが複数取得される可能性があります。例:ソースオフセットでマージしたり、MMRを使用したり)。

文と段落ベースのチャンクング

何であるか。 文や段落の境界でテキストを分割し、文や段落をトークン予算内にパッケージ化します。

エンジニアが好きな理由。 自然言語の連続性を改善し、伝統的な句読点とスペースを使用したドキュメントでは堅牢です。

ツール。 NLTKの sent_tokenize() はデフォルトでPunkt文境界検出を使用し、spaCyは Sentencizer などのルールベースの文境界ツールを提供します(フル依存モデルを使用せずに文分割が必要な場合に役立ちます)。

失敗モード。 非標準の句読点(ログ、チャットトランスクリプト)、表、コード、箇条書きリストは文分割の仮定を破ります。

スライディングウィンドウチャンクング

何であるか。 固定されたウィンドウサイズとステップ(ストライド)を使用してチャンクを作成します。これは「体系的な重複」バージョンのチャンクングです。

良い場面。 時系列テキスト、トランスクリプト、チャットログ、会議録——関連する事実が局所的な近隣に現れる可能性がある場合、堅牢な再現率が必要な場合。

悪い場面。 冗長性を増幅し、大規模な場合に高コストになります。また、重複を削除しない限り、冗長なウィンドウが取得される傾向があります。

再帰的/セパレータ階層チャンクング

何であるか。 大きな「自然」セパレータ(例:\n\n は段落)から始め、サイズ予算を超えないように必要に応じてさらに小さな単位(文、スペース)に再帰的に分割します。LangChain はこの動作を明示的に記述しています:再帰的なスプリッターは、より大きな単位を保持しようとし、単位がまだチャンクサイズを超える場合にのみ、より小さなセパレータにフォールバックします。

なぜ強力なデフォルトか。 構造を尊重することを要求することなく、Markdown、HTML-as-text、ドキュメントに実用的な折衷点を提供します。

重要な調整ノブ。 chunk_sizechunk_overlap、および length_function(文字 vs トークン)、そして多言語コードベース用のカスタムセパレータ。

セマンティック(埋め込み意識)チャンクング

何であるか。 テキストのトピック変化をセマンティック表現(埋め込み)で検出し、類似度が下がる場所で分割します。これは、TextTilingなどの古典的なセグメンテーションのアイデア(語彙の連続性の変化を用いてサブトピックの境界を検出)と一致します。

なぜサイズベースのチャンクングを上回るか。 任意のトークンカウントで分割を停止する代わりに、トピック境界とチャンクを合わせることで、マルチトピックドキュメント(ブログ、設計ドキュメント、チケット、インシデントレポート)の取得精度を改善できます。

コスト。 チャンクング中に追加の埋め込み(文レベルまたは段落レベル)が必要になる場合があります。最終的なチャンクの埋め込み前に中間埋め込みを再利用しない限り、埋め込み呼び出しが2倍または3倍になる可能性があります。

実用的なトリック。 「セマンティック意識パッキング」:文の埋め込みを一度に計算し、文をトピック連続セグメントにグループ化し、各最終セグメントを埋め込みします。

階層的チャンクング(親/子)

何であるか。 複数の粒度の表現を構築します:粗い親チャンク(例:セクションサイズ)と細かい子チャンク(例:段落サイズ)。LlamaIndexの階層ノードパーサーはデフォルトで「粗から細」の階層を生成します(例:2048 → 512 → 128トークンスケール)。AutoMergingRetrieverは、十分な関連子が取得された場合に子ノードを親にマージできます。

なぜ役立つか。 「小さなチャンクで再現性」と「大きなチャンクで連続性」の選択を避けて、両方を保存し、クエリ時に選択します。

コスト。 より複雑なインジェストおよび取得ロジック、およびより多くのストレージ(複数の粒度を保存するため)。

適応的/LLMベースのチャンクング

何であるか。 LLMを使用してチャンク境界を決定します(オプションで要約や文脈ヘッダーを生成)。WeaviateはLLMベースのチャンクングを明示的に説明しており、LLMがセマンティック連続チャンクを作成し、固定ルールや埋め込み類似度に依存することなく、セマンティック連続チャンクを作成します。

価値がある場面。 正確性がコストを上回る高価なコーパス(法的、コンプライアンス、サポートマニュアル)で、ドキュメントが乱雑、異質、セグメンテーションが悪い場合。

リスク。 コスト、レイテンシー、非決定性。キャッシュ、決定的デコード、リグレッションテスト(評価セクションを参照)が必要です。

構造および要素ベースのチャンクング(ドキュメントは単なるテキストではない)

何であるか。 ドキュメントを要素(タイトル、段落、リスト、表、キャプション)に解析し、その要素を使用してチャンクングします。Unstructuredのチャンクング関数は明示的にメタデータとドキュメント要素(分割によって生成された)を使用してRAG用のチャンクを生成します。Doclingの HierarchicalChunker は検出されたドキュメント要素ごとにチャンクを作成し、ヘッダーやキャプションなどの構造メタデータを添付します。

最近の研究からの証拠。 2024年のSEC文書に関する研究では、段落のみのチャンクングはドキュメント構造を無視し、構造要素によるチャンクングを提案しています。それは、構造を無視するアプローチよりもRAGの結果を改善し、チャンクとベクトルの数を減少させたと報告されています。

マルチモーダルへの重要性。 表、図、キャプションは通常、真実値を含んでいます。「フラット化」して単なるテキストにすると、取得が本来利用できる信号を破壊します。

コード意識チャンクング(AST/構造)

何であるか。 構文単位(関数、クラス、モジュール)でコードをチャンクングし、オプションでドキュメンテーション文字列やコメントを含めます。

なぜ重要か。 固定サイズのトークン分割は関数を半分に切り、ドキュメンテーション文字列を実装から分離する傾向があります。これはコード検索や「この関数を説明してください」のRAG用途には悪いです。

実装オプション。 Pythonでは、組み込みの ast モジュールが十分な場合があります。多言語リポジトリでは、tree-sitterベースのチャンクナーが一般的です。

評価の次元とチャンクング戦略の比較方法

チャンクングはシステムコンポーネントとしてベンチマークされるべきです。

取得品質メトリクス

取得レイヤーで標準的なIRメトリクスを使用します:

  • Recall@k / Precision@k:top-kに金の証拠が含まれていますか?
  • MRR / nDCG:金の証拠が順位が高いですか?

BEIRはタスク/ドメインにわたるIR評価のための広く使われている異質なベンチマークであり、疎、密、後期相互作用、再ランキングアプローチの間のトレードオフを強調しています。

チャンクングはこれらのメトリクスに影響を与えます。なぜなら、何が「関連する取得項目」としてカウントされるかを定義するからです。

エンドトゥーエンドRAG回答品質メトリクス

QAやアシスタントを構築している場合、取得メトリクスは必要ですが十分ではありません。また必要:

  • コンテキスト再現率/精度:取得されたコンテキストが関連する証拠を含み、ノイズを避けるか。
  • 信頼性:生成された回答が取得されたコンテキストによってサポートされているか。

RAGASは「信頼性」やその他のRAG向けメトリクスに具体的な定義と実装を提供しています。

システムコストと性能の次元

チャンクングはこれらのレバーグを変更します:

レイテンシー(p50/p95)。ベクトル数と後処理が増えるとクエリのレイテンシーが増加します。ベクトルインデックスも重要です:FAISSのインデックスタイプは検索時間、品質、メモリ、トレーニング/追加時間をトレードオフとしています。[^faiss]

埋め込みコストとスループット。OpenAIの埋め込みはトークンごとに請求され、埋め込みAPIには明示的な入力ごとの制限とリクエストごとの制限があります。[^openai_embed_create] オフラインインジェストでは、バッチAPIはコストを削減し、非リアルタイムのターンアラウンドを提供します。[^openai_batch]

インデックスサイズとメモリ。大まかに言って、N 個の d 次元の float32 ベクトルを保存するには、~4 * N * d バイトかかります(純粋なベクトルに加えてメタデータとインデックスのオーバーヘッド)。チャンクングは N に影響します。埋め込みの次元性は d に影響し、OpenAIの埋め込みAPIは dimensions パラメータを通じて出力次元性を制御できます。[^openai_embed_create]

LLMプロンプト予算。大きなチャンクと重複はプロンプトトークンを膨張させます。これはレイテンシーとコストを増加させ、モデルが一部の文脈にあまり注意を払わない「真ん中で迷子」スタイルの失敗モードを引き起こす可能性があります。実際には、通常以下の手順を行います:

  1. 小さなチャンクを取得
  2. マージ/重複削除
  3. 任意に要約
  4. 紧密な証拠セットをLLMに送信

更新/インジェストコスト。小さなチャンクは部分的な再埋め込みを可能にしますが、記録管理を複雑にします。ストリーミングインジェストでは、決定的な、インクリメンタルなチャンクング(固定またはスライディングウィンドウ)を好み、安定したID(ドキュメントID、オフセット範囲、ハッシュ)を添付します。

実験設計:実用的なベンチマークループ

再現可能なチャンクングベンチマークには通常以下があります:

  • 固定されたコーパススナップショット+固定されたクエリセットと金の証拠(または少なくとも期待される回答スパン)。
  • 固定された埋め込みモデルとベクトルインデックス構成。
  • 「取得専用」の評価(recall@k、nDCG)と「RAG」の評価(信頼性、回答の関連性)。
  • コストテレメトリー:チャンク数、埋め込みトークン数、$/月ストレージ、p95クエリレイテンシー、プロンプトトークン数。

UnstructuredのSEC文書論文は、取得向けのメトリクスとQA精度測定の両方でチャンクング戦略を評価する良い例です。

実践的なガイドライン、意思決定マトリクス、推奨デフォルト

驚くほどよく機能する推奨デフォルト

一般的なドキュメンテーションQA に堅牢な「日1」戦略が必要な場合:

  1. 軽く解析:見出しと基本的なメタデータ(ソース、セクションタイトル、URL/パス、タイムスタンプ)を保持。
  2. 再帰セパレータスプリッター でチャンク化(段落→文→語)し、適度な重複を設定。
  3. 強力な汎用埋め込みモデル で埋め込み。
  4. メタデータ(ドキュメントID、セクション、ACL)付きでインデックス化し、取得時に重複削除。
  5. 評価がギャップを示す場合に限り、再ランキング または階層マージ を追加。

これは、一般的なRAGフレームワークがチャンク重複と構造を尊重した分割の説明と一致しています。

どのチャンクング方法を使用するか - 意思決定マトリクス

どのチャンクング方法を使用するか - 図

使用ケース 推奨チャンクングデフォルト 重要な調整パラメータ 一般的な失敗モード アップグレードパス
ドキュメントの短文QA(FAQ、内部ウィキ) 再帰/セパレータチャンクング+重複 chunk_size、重複、top-k 境界で跨る文の証拠が欠如 セマンティックチャンクングまたは再ランカーを追加
長文QA(ポリシー、標準、マニュアル) 階層チャンクング+マージリトリーバー 親/子サイズ、マージしきい値 小さな断片を取得;LLMが全体の文脈を欠く 自動マージ/階層リトリーバー
要約(ドキュメントごと/セクションごと) 構造を意識したチャンク(セクション) セクション検出、最大トークン 要約がセクション間のリンクを欠く 階層要約+セクショングラフ
コード検索&「この関数を説明してください」 AST/関数レベルチャンク ドキュメンテーション文字列/コメントの含否、最大トークン 関数が分割;署名/使用法が失われる レポジトリ意識階層(モジュール→クラス→関数)
マルチモーダルPDF(表/図) 要素ベースチャンクング(タイトル/表/キャプション意識) 表のシリアライズ、キャプションマージ 表の内容が失われるまたは破損 Docling/Unstructured+構造化シリアライザを使用
ストリーミングインジェスト(ログ、チャット、チケット) スライディングウィンドウまたは固定サイズトークン ウィンドウ、ストライド、重複削除 冗長なウィンドウの過取得 バッチにセマンティック境界検出を追加

チャンクング - 質的な性能比較

これは「変化の方向性の期待」として扱ってください(ご自身のデータで検証してください)。

戦略 取得精度の可能性 取得された文脈の連続性 インジェスト複雑性 ベクトル数/インデックスサイズ 埋め込みコスト クエリレイテンシーへの影響 最適な用途
固定サイズ(重複なし) すばやいプロトタイプ、均質なテキスト
固定サイズ+重複 中~高 中~高 中~高 境界損失が問題になるQA
文/段落パッキング 高(プロセ) ドキュメント、記事、クリーンなプロセ
スライディングウィンドウ 高い再現率 非常に高 非常に高 転写、ログ、チャット
再帰/セパレータ 「デフォルト」ドキュメントRAG
セマンティックチャンクング 高~非常に高 マルチトピックページ、物語的なテキスト
階層的(親/子) 非常に高 非常に高 長いマニュアル/標準
LLMベース/適応的 非常に高 非常に高 非常に高 非常に高 中~高 高リスクコーパス
要素/構造ベース 高~非常に高 低~中 PDF、レポート、表、混合レイアウト
コード意識(AST) 高(コード) コード検索、レポジトリアシスタント

DevOpsとハードウェアノート(しばしば見過ごされる)

チャンクングの選択は、どのくらいのインフラが必要か に影響を与えます:

  • 小さなチャンク → ベクトル数が増加 → インデックスサイズとRAM/DISKが増加。ローカルで埋め込みを行う場合、埋め込みスループットはGPUスケジューリングの問題になります。API経由で埋め込む場合、トークン量はFinOpsの問題になります(バックフィルにはバッチAPIが友達です)。
  • 一部のエンジン(FAISS)はGPU加速検索を提供しており、コストはRAM制限のCPUからGPUメモリとPCIeスループットにシフトします。
  • 構造を意識したパース(PDFレイアウト、OCR、表抽出)は通常CPU制限され、スキャンされたドキュメントでは埋め込みコストを大きく上回る可能性があります。別途予算を組みましょう。

チャンキング - Pythonの実装例

すべての例は読みやすく実行可能なように設計されています。 APIキーまたは動作中のDBが必要な場合は、コードから明確にわかります。

共有ユーティリティ: トークンカウントと安定したチャンクID

from __future__ import annotations

import hashlib
from dataclasses import dataclass
from typing import Any, Iterable, Optional

from transformers import AutoTokenizer  # pip install transformers

@dataclass(frozen=True)
class Chunk:
    text: str
    meta: dict[str, Any]

def sha1_id(*parts: str) -> str:
    h = hashlib.sha1()
    for p in parts:
        h.update(p.encode("utf-8"))
        h.update(b"\x1e")
    return h.hexdigest()

# 使用するトークナイザは、LLM/埋め込みのトークナイズにほぼ一致するものにしてください。
TOKENIZER = AutoTokenizer.from_pretrained("bert-base-uncased")

def token_len(text: str) -> int:
    return len(TOKENIZER.encode(text, add_special_tokens=False))

固定サイズのトークンチャンキング

def chunk_fixed_tokens(
    text: str,
    *,
    chunk_size: int = 512,
) -> list[Chunk]:
    token_ids = TOKENIZER.encode(text, add_special_tokens=False)
    out: list[Chunk] = []

    for i in range(0, len(token_ids), chunk_size):
        window = token_ids[i : i + chunk_size]
        chunk_text = TOKENIZER.decode(window)
        out.append(
            Chunk(
                text=chunk_text,
                meta={"strategy": "fixed_tokens", "start_token": i, "end_token": i + len(window)},
            )
        )
    return out

固定サイズ + オーバーラップとスライディングウィンドウ

def chunk_sliding_window(
    text: str,
    *,
    window_tokens: int = 512,
    stride_tokens: int = 384,  # ストライドが小さいほどオーバーラップが多い
) -> list[Chunk]:
    assert 1 <= stride_tokens <= window_tokens, "ストライドは(0, window]の範囲内でなければなりません"
    token_ids = TOKENIZER.encode(text, add_special_tokens=False)
    out: list[Chunk] = []

    start = 0
    while start < len(token_ids):
        end = min(start + window_tokens, len(token_ids))
        window = token_ids[start:end]
        out.append(
            Chunk(
                text=TOKENIZER.decode(window),
                meta={"strategy": "sliding_window", "start_token": start, "end_token": end},
            )
        )
        if end == len(token_ids):
            break
        start += stride_tokens

    return out

文ベースのチャンキング(NLTK)とトークン予算のパッキング

# pip install nltk
import nltk
from nltk.tokenize import sent_tokenize

nltk.download("punkt", quiet=True)

def chunk_by_sentences_nltk(
    text: str,
    *,
    max_tokens: int = 512,
    overlap_sentences: int = 1,
) -> list[Chunk]:
    sents = [s.strip() for s in sent_tokenize(text) if s.strip()]
    out: list[Chunk] = []

    buf: list[str] = []
    buf_tokens = 0

    def flush():
        nonlocal buf, buf_tokens
        if not buf:
            return
        chunk_text = " ".join(buf).strip()
        out.append(
            Chunk(
                text=chunk_text,
                meta={"strategy": "sentences_nltk", "sent_count": len(buf)},
            )
        )
        # オーバーラップするために最後のN文を保持
        if overlap_sentences > 0:
            buf = buf[-overlap_sentences:]
            buf_tokens = token_len(" ".join(buf))
        else:
            buf, buf_tokens = [], 0

    for s in sents:
        s_tokens = token_len(s)
        # 単一の文が予算を超える場合、そのスパンに対して固定サイズのチャンキングにフォールバック
        if s_tokens > max_tokens:
            flush()
            out.extend(chunk_fixed_tokens(s, chunk_size=max_tokens))
            continue

        if buf_tokens + s_tokens > max_tokens and buf:
            flush()

        buf.append(s)
        buf_tokens += s_tokens

    flush()
    return out

文ベースのチャンキング(spaCy)でルールベースまたはモデルベースのSBDが必要な場合

# pip install spacy
# python -m spacy download en_core_web_sm
import spacy

def chunk_by_sentences_spacy(
    text: str,
    *,
    max_tokens: int = 512,
) -> list[Chunk]:
    # 軽量なルールベースの分割(依存関係解析なし)を使用する場合、sentencizerを使用します。
    nlp = spacy.blank("en")
    nlp.add_pipe("sentencizer")  # ルールベースの文の境界検出
    doc = nlp(text)

    sents = [sent.text.strip() for sent in doc.sents if sent.text.strip()]
    return chunk_by_sentences_nltk(" ".join(sents), max_tokens=max_tokens, overlap_sentences=1)

再帰的なセパレータチャンキング(LangChain)

# pip install langchain-text-splitters
from langchain_text_splitters import RecursiveCharacterTextSplitter

def chunk_recursive_langchain(
    text: str,
    *,
    chunk_size: int = 1200,
    chunk_overlap: int = 150,
) -> list[Chunk]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=token_len,  # トークンを意識した予算配分
        separators=["\n\n", "\n", ". ", " ", ""],  # コンテンツに合わせてカスタマイズ(例:コード)
    )
    pieces = splitter.split_text(text)

    return [
        Chunk(text=p, meta={"strategy": "recursive_langchain", "chunk_size": chunk_size, "overlap": chunk_overlap})
        for p in pieces
    ]

埋め込み類似度によるセマンティックチャンキング(OpenAI埋め込み)

このアプローチでは、候補ユニット(文/段落)の埋め込みを計算し、その後セマンティックな「ブレイクポイント」を見つける。

# pip install openai numpy
import os
import numpy as np
from openai import OpenAI

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY", "YOUR_OPENAI_API_KEY"))

def embed_texts_openai(texts: list[str], *, model: str = "text-embedding-3-small") -> np.ndarray:
    # バッチが大きい場合、リクエストトークン制限とバッチサイズ制限を尊重してください。
    resp = client.embeddings.create(model=model, input=texts)
    embs = np.array([d.embedding for d in resp.data], dtype=np.float32)
    return embs

def cosine_sim(a: np.ndarray, b: np.ndarray) -> float:
    denom = (np.linalg.norm(a) * np.linalg.norm(b)) + 1e-8
    return float(np.dot(a, b) / denom)

def chunk_semantic(
    text: str,
    *,
    max_tokens: int = 800,
    breakpoint_threshold: float = 0.70,
) -> list[Chunk]:
    # 1) 文の候補から開始
    sents = [s.strip() for s in sent_tokenize(text) if s.strip()]
    if len(sents) <= 1:
        return [Chunk(text=text, meta={"strategy": "semantic", "note": "no_split"})]

    # 2) 各文の埋め込み
    embs = embed_texts_openai(sents)

    # 3) 類似度の減少を計算
    sims = [cosine_sim(embs[i], embs[i + 1]) for i in range(len(sents) - 1)]

    # 4) ブレイクポイントでセグメントを作成
    out: list[Chunk] = []
    buf: list[str] = []
    buf_tokens = 0

    for i, s in enumerate(sents):
        # 文を追加
        s_tok = token_len(s)
        if buf and buf_tokens + s_tok > max_tokens:
            out.append(Chunk(text=" ".join(buf), meta={"strategy": "semantic", "reason": "max_tokens"}))
            buf, buf_tokens = [], 0
        buf.append(s)
        buf_tokens += s_tok

        # 文iの後のブレイクポイントを決定(次の文との類似度に基づく)
        if i < len(sims) and sims[i] < breakpoint_threshold:
            out.append(
                Chunk(
                    text=" ".join(buf),
                    meta={"strategy": "semantic", "reason": "sim_drop", "sim_to_next": sims[i]},
                )
            )
            buf, buf_tokens = [], 0

    if buf:
        out.append(Chunk(text=" ".join(buf), meta={"strategy": "semantic", "reason": "final"}))

    return out

階層的なチャンキング + リトリーブルのマージ(LlamaIndex)

# pip install llama-index llama-index-llms-openai
from llama_index.core import Document, StorageContext, VectorStoreIndex
from llama_index.core.node_parser import HierarchicalNodeParser, get_leaf_nodes
from llama_index.core.retrievers import AutoMergingRetriever
from llama_index.core.storage.docstore import SimpleDocumentStore

def build_hierarchical_index(text: str):
    docs = [Document(text=text)]

    node_parser = HierarchicalNodeParser.from_defaults()  # デフォルトは粗から細のサイズ
    nodes = node_parser.get_nodes_from_documents(docs)

    docstore = SimpleDocumentStore()
    docstore.add_documents(nodes)
    storage_context = StorageContext.from_defaults(docstore=docstore)

    leaf_nodes = get_leaf_nodes(nodes)
    base_index = VectorStoreIndex(leaf_nodes, storage_context=storage_context)

    base_retriever = base_index.as_retriever(similarity_top_k=6)
    retriever = AutoMergingRetriever(base_retriever, storage_context, verbose=True)
    return retriever

PDFの要素ベースチャンキング(Docling)

# pip install docling
# NOTE: PDFのパース品質は環境(フォント、OCRなど)に依存します。
from docling.document_converter import DocumentConverter
from docling.transforms.chunker.hierarchical_chunker import HierarchicalChunker

def chunk_pdf_docling(pdf_path: str) -> list[Chunk]:
    converter = DocumentConverter()
    doc = converter.convert(pdf_path).document  # DoclingDocument
    chunker = HierarchicalChunker()
    doc_chunks = list(chunker.chunk(doc))

    out: list[Chunk] = []
    for c in doc_chunks:
        # c.textにはシリアル化されたチャンク内容が含まれています;c.metaには構造情報が含まれます
        out.append(Chunk(text=c.text, meta={"strategy": "docling_hierarchical", **dict(c.meta)}))
    return out

Pythonのコードを意識したチャンキング(AST)

import ast

def chunk_python_by_ast(code: str, *, filepath: str = "<memory>") -> list[Chunk]:
    tree = ast.parse(code)
    out: list[Chunk] = []

    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
            start = getattr(node, "lineno", None)
            end = getattr(node, "end_lineno", None)
            if start is None or end is None:
                continue

            lines = code.splitlines()
            snippet = "\n".join(lines[start - 1 : end])

            kind = "class" if isinstance(node, ast.ClassDef) else "function"
            name = getattr(node, "name", "<anon>")
            out.append(
                Chunk(
                    text=snippet,
                    meta={
                        "strategy": "python_ast",
                        "kind": kind,
                        "name": name,
                        "filepath": filepath,
                        "start_line": start,
                        "end_line": end,
                    },
                )
            )

    return out

インデクシングの例

FAISS(ローカル) — 最小限で高速なベースライン

# pip install faiss-cpu numpy
import numpy as np
import faiss

def build_faiss_index(vectors: np.ndarray) -> faiss.Index:
    # vectors: shape (N, d), dtype float32
    d = vectors.shape[1]
    index = faiss.IndexFlatIP(d)  # 内積;ベクトルが正規化されている場合、コサイン類似度
    faiss.normalize_L2(vectors)
    index.add(vectors)
    return index

def faiss_search(index: faiss.Index, query_vec: np.ndarray, k: int = 5):
    q = query_vec.astype(np.float32).reshape(1, -1)
    faiss.normalize_L2(q)
    scores, ids = index.search(q, k)
    return ids[0].tolist(), scores[0].tolist()

Chroma(ローカル) — RAGプロトタイピング向けの簡単な永続性

# pip install chromadb
import chromadb

def build_chroma_collection(chunks: list[Chunk], embeddings: np.ndarray, *, path: str = "./chroma_store"):
    client = chromadb.PersistentClient(path=path)
    col = client.get_or_create_collection(name="docs")

    ids = [sha1_id(c.meta.get("strategy", "chunk"), str(i), c.text[:50]) for i, c in enumerate(chunks)]
    col.upsert(
        ids=ids,
        documents=[c.text for c in chunks],
        metadatas=[c.meta for c in chunks],
        embeddings=embeddings.tolist(),
    )
    return col

Weaviate(セルフホスト/クラウド) — 自分でベクトルを提供

# pip install weaviate-client
import weaviate
from weaviate.classes.config import Configure

def weaviate_upsert_self_provided(
    chunks: list[Chunk],
    embeddings: np.ndarray,
):
    client = weaviate.connect_to_local()  # またはconnect_to_weaviate_cloud(...)
    try:
        collection = client.collections.create(
            name="Chunk",
            vector_config=Configure.Vectors.self_provided(),  # あなたがベクトルを提供
        )

        with collection.batch.dynamic() as batch:
            for c, v in zip(chunks, embeddings):
                batch.add_object(
                    properties={"text": c.text, **{f"m_{k}": str(vv) for k, vv in c.meta.items()}},
                    vector=v.tolist(),
                )

        # near-vectorでクエリ
        q = embeddings[0]
        res = collection.query.near_vector(near_vector=q.tolist(), limit=5)
        for obj in res.objects:
            print(obj.properties.get("text")[:200])
    finally:
        client.close()

一部のソースドキュメント

  • Patrick Lewis et al., “Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks”, NeurIPS 2020; arXiv:2005.11401.
  • Vladimir Karpukhin et al., “Dense Passage Retrieval for Open-Domain Question Answering”, EMNLP 2020; arXiv:2004.04906.
  • OpenAI APIリファレンス: 埋め込みの作成(/v1/embeddings) — トークン制限(1入力あたり8192;1リクエストあたり300k)とdimensionsパラメータ。
  • OpenAIバッチAPIガイド + OpenAI API料金ページ(バッチで約50%節約、24時間のターンアラウンド)。
  • LangChainドキュメント: RecursiveCharacterTextSplitterとsplitters統合ガイド(チャンクサイズ/オーバーラップ、再帰的なセパレータ階層)。
  • LlamaIndexドキュメント: HierarchicalNodeParserとAutoMergingRetriever(粗から細のノード;リトリーブ時マージ)。
  • Weaviateブログ: “Chunking Strategies to Improve LLM RAG Pipeline Performance”(LLMベースのチャンキングの説明とトレードオフ)。
  • Doclingドキュメント: HierarchicalChunkerはドキュメント要素構造からチャンクを作成し、見出し/キャプションメタデータを添付。
  • Jimeno Yepes et al., “Financial Report Chunking for Effective Retrieval Augmented Generation” (arXiv:2402.05131v3, 2024).
  • Marti A. Hearst, “TextTiling: Segmenting Text into Multi-Paragraph Subtopic Passages”, Computational Linguistics, 1997 (ACL Anthology: J97-1003).
  • FAISSドキュメント / GitHubリポジトリ: 検索時間、品質、メモリのトレードオフ;オプションのGPUサポート。
  • Nandan Thakur et al., “BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models”, NeurIPS 2021; arXiv:2104.08663.
  • RAGASドキュメント: “Faithfulness"メトリクスと関連するRAG評価メトリクス。
  • NLTKドキュメント: nltk.tokenize.sent_tokenize - Punktベースの推奨文トークナイザ。
  • spaCy APIドキュメント: Sentencizer - 依存関係解析なしのルールベースの文境界検出。

その他の有用なリンク