チャンキング戦略 — 分け方で検索は決まる
RAGの精度の大半は「文書をどう切るか」で決まる。固定長とオーバーラップの算数、構造分割、意味分割、親子チャンク、そして鬼門である表と数式の扱いまでを1から解説する。
要点
- チャンキングは検索の前工程であり、ここで落とした情報は後段のどんなモデルでも回復できない。
- チャンクが大きすぎると希釈、小さすぎると文脈の欠落が起き、事故の形が違うので最適値は文書の種類ごとに違う。
- 固定長分割ではオーバーラップ20%なら埋め込み費用とインデックス容量は約1.25倍、50%なら約2倍になる。
- 構造がある文書には構造分割、話題が流れる文書には意味分割が現実的な使い分けである。
- 親子チャンクは検索用の索引とLLMに渡す本体を別々に持ち、当てる粒度と読ませる粒度を切り離す。
出典: Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks · Computing Embeddings · Auto Merging Retriever · Best Practices for using Rerank ほか1件(末尾)
分厚いマニュアルを、裁断機にかける
手元に500ページの業務マニュアルがあるとします。必要なときに必要な箇所だけ取り出せるようにしたい。あなたは綴じを落としてバラにし、ファイルすることにしました。ここで最初の決断が必要になります。どの単位で束ねるかです。
1枚ずつバラバラにすれば、狙ったページをピンポイントで取り出せます。しかし1ページ目の途中で始まった説明が2ページ目に続いていたら、片方だけ取り出しても意味が通りません。逆に章ごとに束ねれば話は途切れませんが、「この30ページの束のどこかに書いてある」という粗さになり、束そのものが何の話なのかもぼやけます。
RAG(検索拡張生成)におけるチャンキングは、まさにこの裁断作業です。そして厄介なことに、この工程は検索の前にあります。切り方を間違えると、後段でどんなに高性能な埋め込みモデルやリランカーを使っても取り返せません。全体の流れはRAGの基礎と設計パターンにありますが、この記事はその一工程だけを深掘りします。
なぜ分けなければならないのか
理由は3つあり、どれも独立しています。
1つ目は、埋め込みが1本のベクトルだからです。テキストを数百次元の数値に変換するとき、入力が長いほど「その中の多様な話題」が1本の向きに押し込められます。休暇規程と経費精算と防災訓練が同居した塊は、どの話題からも中途半端に遠い、当たり障りのない方向を向いてしまいます。埋め込みの仕組みは埋め込み(Embedding)を1から理解するを参照してください。
2つ目は、LLMに渡せる量に上限があることです。コンテキスト窓は有限で、トークン数はそのまま費用と応答時間になります。
3つ目は、引用の粒度がそのまま根拠の粒度になることです。「この30ページのどこかが根拠です」と言われても、利用者は検証できません。
大きすぎる失敗と、小さすぎる失敗は形が違う
チャンクが大きすぎるとき起きるのは希釈です。質問と本当に関係があるのは塊の中の3行なのに、残りの数百行が平均化に加わり、類似度が薄まって上位に上がってこない。運よく上位に来ても、無関係な文がコンテキストの大半を占め、生成側が根拠を取り違える確率が上がります。
チャンクが小さすぎるとき起きるのは文脈の欠落です。「同社は」「この場合は」といった指示語の参照先が別チャンクに残る。表の1行だけが切り出されて列見出しが失われる。条文の但し書きだけが単独で検索に引っかかる。どれも、断片としては正しいのに答えとしては誤りを生みます。
つまり両側に別種の事故があり、最適値は文書の種類ごとに違う。「万能のチャンクサイズ」が存在しない理由がここにあります。
さらに、チャンク長を変えると埋め込みベクトルの長さ(ノルム)の分布まで動きます。正規化していない内積で検索していると、それだけで長いチャンクが不当に上位へ入り込みます。下の図でクエリをドラッグし、物差しを内積・コサイン・L2で切り替えてみてください。同じ文書集合でも上位の顔ぶれが入れ替わります。
固定長分割: まず算数を押さえる
もっとも単純な方法は、一定の長さで機械的に切り、境界で文が途切れる問題をオーバーラップ(前のチャンクの末尾を次の先頭に重ねる)で緩和するやり方です。実装は RecursiveCharacterTextSplitter のような分割器に chunk_size と chunk_overlap を渡すだけで、多くの現場の出発点になっています。
押さえるべきは、この2つがインデックスの規模と費用を直接決めるという点です。文書全体の長さを 、チャンク長を 、オーバーラップを とすると、チャンクの個数は次のようになります。
つまりこの式は「文書は何枚に切れるか」を数えているだけです。1枚ぶんの長さ のうち、末尾の は次の紙にもう一度写すので、前へ進む距離は しかない。最後に半端が出たらそれも1枚と数える、というのが両側の切り上げ記号 の意味です。
分母の が「1チャンク進むごとに新しく読む量」(ストライド)です。オーバーラップを増やすと1歩の幅が縮むので、同じ文書でもチャンク数が増えます。オーバーラップ率を と置くと、埋め込みに投げる総トークン数はもっと直感的な形になります。
左辺の は全チャンクの長さを足し合わせた量、つまり埋め込みモデルに実際に読ませる総量です。これが元の文書の長さ を で割った値に近づく、というのがこの式の言っていることです。
オーバーラップ20%なら埋め込み費用とインデックス容量は約1.25倍、50%なら約2倍という意味です。式(2)は「重ねた分だけ同じ文字を二度買っている」と言っているにすぎません。オーバーラップは無料の保険ではありません。
もう1つの落とし穴は長さの単位です。日本語は1文字が複数トークンになることも珍しくなく、「1000文字」のつもりが埋め込みモデルの入力上限を超えて末尾が黙って捨てられる、という事故が起きます(トークナイザを1から理解する)。分割器には本番と同じトークナイザを渡すのが原則です。
構造で切る: 文書がすでに持っている境界を使う
固定長分割は文書を「文字の列」としてしか見ていません。しかし実際の文書には、書いた人が意図した境界がすでにあります。Markdownの見出し、HTMLの節、規程の条番号、API文書のエンドポイント単位。これらで切れば、1チャンク=1つの話題という理想に近づきます。
効果が大きいのは、切ることそのものより見出しの階層をチャンク本文の先頭に埋め込む工夫です。
# 見出しのパンくずを本文の先頭に足してから埋め込む
header = " > ".join(["就業規則", "第5章 休暇", "第21条 有給休暇"])
text_to_embed = f"{header}\n\n{chunk_body}"
こうすると、本文が「前項の日数を上限として繰り越すことができる」だけの断片でも、ベクトルは「休暇の話」という方向を持てます。人間が目次を見て場所を把握するのと同じことを、埋め込みにさせているわけです。
意味で切る: 話題が変わる場所を探す
構造が乏しい文書(議事録、書き起こし、長文の解説)では、内容そのものから境界を推定します。セマンティック・チャンキングです。
文ごとに埋め込みを取り、隣り合う文の類似度を並べます。 番目の文の埋め込みを として、
つまり、隣り合う2つの文をそれぞれ矢印に変えて、その2本がどれくらい同じ方向を向いているかを測っています。1に近ければ同じ方向=同じ話の続き、0に近ければ別方向=話が切り替わった、ということです。
は「 番目の文と次の文が、どれくらい同じ話をしているか」の指標です。この値が急に落ち込む位置を話題の変わり目とみなして切ります。閾値は絶対値で決めるより、その文書内の の分布のパーセンタイル(例えば下位5%)で決めるほうが、文書ごとの性質の違いに強くなります。
正直に書くと、意味分割が常に固定長に勝つわけではありません。前処理が重く、閾値はドメインごとの調整が要り、箇条書きが続く文書では類似度が乱高下して切りどころを見失います。構造がある文書には構造分割、話題が流れる文書には意味分割が現実的な使い分けです。
親子チャンク: 探すのは小さく、渡すのは大きく
「小さいほうが検索は当たる」「大きいほうが文脈は足りる」——この2つは両立できます。検索用の索引と、LLMに渡す本体を別々に持てばよいのです。
hits = index.search(query, k=20) # 小チャンクでヒットさせ
parents = {h.parent_id for h in hits} # 親IDに畳んで重複を消し
context = [store.get(pid) for pid in parents] # 大きい塊を渡す
この構成は small-to-big、親子チャンク、実装によって sentence-window(ヒットした文の前後 文を復元する)や auto-merging(同じ親の子が一定数ヒットしたら親に昇格させる)とも呼ばれます。発想は同じで、当てる粒度と読ませる粒度を切り離すことです。親IDを集合にしているのは重複排除のためで、これを忘れると top_k は10なのに同じ段落が5回入る、という無駄が起きます。
鬼門: 表・数式・コード
散文向けの分割器は、この3つで高い確率で壊れます。
表は、行だけが切り出されると列見出しを失います。「120,000」という数字がヒットしても、それが何の金額なのか分からない。対策は、表全体を1チャンクにするか、行ごとに切るなら各行に列見出しを繰り返し埋め込む(部署: 営業 / 費目: 交通費 / 上限額: 120,000 のように自己完結させる)かです。表題や直前の説明文を各チャンクに添えると、引きがさらに変わります。
数式は2段階で厄介です。まずPDFやHTMLからの抽出時点で崩れやすい。そして仮に がきれいに取れても、\frac{\partial L}{\partial w} という文字列は埋め込みモデルにとって意味の薄い記号列で、「勾配」という日本語のクエリとはほとんど結びつきません。実務的な対処は、式そのものを検索の当たり所にしないことです。式は本文と同じチャンクに含めたうえで、検索は式の前後の説明文(「損失を重みで偏微分すると」)に任せる。式番号やキャプションも同じチャンクに入れておきます。
コードは関数やクラスの境界で切ります。固定長で切ると def の行を失った断片ができ、何をする関数なのか分からなくなります。多くの分割器が言語ごとの区切り文字を持っているので、それを使うか構文解析でトップレベル定義ごとに切り出します。
top_k はチャンク長と連動する
冒頭の図で見たノルムの問題に加えて、件数の釣り合いも動きます。チャンクを半分の長さにしたなら、同じ情報量を渡すには top_k を倍にしないと釣り合いません。チャンクサイズだけ変えて top_k を据え置いた比較は、実質「渡すコンテキスト量」を変えた比較になっていて、何が効いたのか分からなくなります。
決め方: 分割を変えても比較できる評価を作る
チャンク戦略の比較には固有の罠があります。検索精度を Recall@k(上位k件に正解が入っている率)で測るとき、「正解チャンク」の定義が分割方法ごとに変わってしまうからです。分割を変えるたびに正解を付け直していては比較になりません。
回避策は、正解をチャンクではなく原文のスパンで持つことです。評価セットには「質問」と「文書ID+答えが書かれている原文の文字範囲」を記録し、分割後はそのスパンに重なるチャンクを正解とみなす。こうすれば固定長でも構造分割でも意味分割でも、同じ評価セットで横並びに測れます。
現場ではこう使う
チャンキングを実際に触るのは、社内文書QAや問い合わせ支援を作るバックエンド/MLエンジニアです。ゼロから設計するより「動いてはいるが精度が出ないRAGを渡されて改善する」場面が多く、そのとき最初に疑うべき工程がここです。
触るパラメータとツール: 分割器の chunk_size / chunk_overlap、トークナイザ指定(本番の埋め込みモデルと同じものを渡す)、Markdown見出し分割器のヘッダ階層、意味分割のパーセンタイル閾値、親子取得の親サイズ、検索側の top_k とリランカーの入力最大長。
知らないと事故になる落とし穴:
- 分割を変えたら全件を再インデックスする。旧チャンクを残したまま新チャンクを足すと同じ本文が二重に載り、
top_kの枠を重複が食いつぶす - 抽出の失敗を分割の失敗と誤診しない。PDFの2段組が行ごとに混ざる、ヘッダ・フッタが全ページに紛れ込む、といった抽出段の壊れは、チャンクサイズをいくらいじっても直らない。まず抽出結果のテキストを数十件、目で見る
- 埋め込みモデルの入力上限を超えた分は黙って切り捨てられることがある。長いチャンクにしたのに末尾が索引に入っていない、という無言の欠落が起きる
- リランカーにも入力長の上限がある。1段目で拾った大きなチャンクが切り詰められると、採点しているのは冒頭だけ、ということになる
設計レビューで問われる形: 「チャンクサイズはどう決めましたか」に数字だけで答えると弱い。文書の種類(構造の有無)→ 分割方式の選択 → 原文スパンで持った評価セットでの Recall@k 比較 → top_k まで条件を揃えた検証、という筋道で答えられると設計として通ります。
まとめ
- チャンキングは検索の前工程。ここで落とした情報は、後段のどんなモデルでも回復できない
- 大きすぎると希釈、小さすぎると文脈欠落。事故の形が違うので最適値は文書の種類ごとに違う
- 固定長は費用が算数で読める(オーバーラップ率 で 倍)。構造がある文書は構造で、話題が流れる文書は意味で切る
- 親子チャンクは「当てる粒度」と「読ませる粒度」を分離する定番手。表は列見出しを各チャンクに、数式は検索を前後の説明文に任せる
- 評価の正解は原文のスパンで持つ。そうしないと分割方式どうしを比較できない
次は、切ったチャンクをどう拾い直すか——ハイブリッド検索とリランキングの設計に進むと、この記事で決めた「粒度」の判断が生きてきます。
コメント
コメントにはログインが必要です