TT Lab
はじめる
学ぶ 学習パス コース

LLMエンジニアリング

チャンキング — 検索の単位を決めることが半分だ

TT Labで続きを見る

一言でいうと

チャンクは、検索の最小単位であり、コンテキストに入る単位です。大きすぎるとノイズが混ざり、小さすぎると文脈が途切れます。よいチャンクの基準は1つです。そのチャンクだけを読んで、質問1つに答えられることです。

なぜ必要なのか

40ページの運用マニュアルを、まるごと1件としてインデックスしたとします。「バックアップの保持期間は?」と尋ねると、マニュアルが1位で出てきます。検索は成功です。ところが、コンテキストに40ページを入れるとトークン予算を超え、入れられたとしても、正解の1行が長い文章の真ん中に埋もれて、モデルが見逃します。逆に、マニュアルを1行ずつ切ってインデックスすると、「その値は30日です」という行が出てきますが、何の値なのかは前の行にあって、わかりません。

文書をまるごとインデックスすると、2つのことが崩れます。1つ目に、長い文書は複数のトピックを含んでいて、ベクトルが平均に潰れます。2つ目に、検索に成功しても、コンテキストに文書全体を入れる必要があり、トークンが爆発します。そのため、文書を切る必要がありますが、どう切るかが、検索品質を左右します。そして、一度決めた方式は、変更するのに費用がかかります。チャンキング戦略を変えると、全体をインデックスし直す必要があるからです。

どう動くのか

固定長分割は、N文字またはNトークンごとに切る方式です。実装が単純で、サイズが予測可能ですが、文の途中で切れて意味が壊れます。長さを測るときは、文字よりトークンのほうがよいです。埋め込みモデルと生成モデルの上限が、どちらもトークンで決まっているからです。

区切り文字ベースの分割は、段落や文の境界で切ります。意味の単位を保てますが、長さが不揃いになります。実務では、まず段落で分け、長すぎる段落だけを文でもう一度分け、短すぎる断片は隣と合わせるという具合に、2つの方式を混ぜます。

オーバーラップ(overlap)は、隣り合うチャンクが一部を共有するようにする技法です。境界にまたがった情報がどちらからも検索されない問題を緩和します。代償は、保存量と重複検索です。400トークンのチャンクに50トークンを重ねると、チャンクが350トークンごとに始まるので、チャンクの数が約14%増え、同じ内容が上位の結果に2回上がって、コンテキストの場所を無駄にすることもあります。

階層的分割は、小さなチャンクで検索し、そのチャンクが属する大きな単位(段落や節)をコンテキストとして入れる方式です。検索の精度と文脈の完結性を両方得ようとする折衷案です。

メタデータは、チャンクの足りない文脈を補います。文書のタイトル、節のパス、更新日をチャンクに付けておくと、検索結果に文脈を加え、フィルタリングにも使えます。チャンクのテキストの前にタイトルと節の名前を付け足してインデックスするだけで、検索品質が目に見えて上がることが多くあります。

サイズを決める実用的な基準に戻ると、チャンク1つを切り取って読んだときに、「それは」が何を指すのかわからないなら、文脈が足りないのであり、1つのチャンクに互いに無関係なトピックが3つ入っているなら、ベクトルが潰れているのです。

現場で何が問題になるのか

ピリオドで切って、おかしな場所で切れる。ピリオドは文末にだけ来るわけではありません。3.5、v1.2、ドメイン名、略語、省略記号で文が分かれます。逆に、リスト、表、コードのようにピリオドがない部分は、1つにつながって巨大なチャンクになります。症状は、チャンクの長さの分布の両端に、数文字の断片と数千文字のかたまりが一緒に見えることです。

表とコードブロックが引き裂かれる。固定長で切ると、表のヘッダーと本体が分離して、両方とも役に立たなくなります。数字だけが残ったチャンクは、何の数字かわかりません。ヘッダーを断片ごとに繰り返して入れるか、表はまるごと1つのチャンクにする、という例外処理が必要です。

チャンクの末尾が黙って切れる。埋め込みモデルには入力長の上限があり、それより長いテキストは、たいてい前の部分だけを残して切られます。たとえばsentence-transformersは、モデルのmax_seq_lengthを超える入力を、その長さまでしか使わず、BERT系でよくある値は512トークンです。エラーは出ません。チャンクの後ろの部分にあった内容はベクトルに反映されず、永遠に検索されません。チャンクの最大長は、埋め込みモデルの上限より小さくします。

チャンク番号がずれる。チャンクidを「文書の何番目の断片」としてだけ付けると、文書の前の部分に文が1つ追加されただけでも、後ろの番号がすべて1つずつずれます。評価セットの正解ラベル、キャッシュ、ユーザーフィードバックの記録が、すべて別のチャンクを指すことになります。長く使うシステムなら、文書idと内容のハッシュで、安定したidを作ります。

どう確認するのか

チャンクを作ったら、インデックスする前に、分布とサンプルを見ます。

import statistics, random

rows = [line.rstrip("\n").split("\t", 2)
        for line in open("chunks.tsv", encoding="utf-8")]
lens = [len(r[2]) for r in rows]
print(len(rows), min(lens), statistics.median(lens), max(lens))
print(sum(1 for n in lens if n < 15), "very short chunks")
for r in random.sample(rows, 5):
    print(r[0], r[1], r[2])

数値では、最小値と最大値をまず見ます。非常に短い断片が多いなら、分割ルールが文ではない場所で切っているのであり、非常に長い断片があるなら、区切りのない領域があるのです。ランダムなサンプル5個は、声に出して読んでみます。チャンク1つだけを見て、「これでどんな質問に答えられるか」を言えないなら、そのチャンクは、検索に引っかかっても役に立ちません。

評価の質問があるなら、もう1つ行います。各質問の正解の文が、1つのチャンクの中に完全に入っているかを確認することです。正解が2つのチャンクにまたがっているなら、その質問は、どんなリトリーバーでも完全には当てられません。最後に、同じクエリを、文書単位のインデックスとチャンク単位のインデックスでそれぞれ実行してRecall@Kを比べれば、チャンキングが実際に役に立ったかを、数値で言えます。

次のラボですること

文書30件をピリオドを基準に切って90個あまりのチャンクに分け、前後の空白を削除して空の断片を捨てるルールを守って保存します。そのチャンク単位でTF-IDFのインデックスを作って8個のクエリを検索し、Recall@3とMRRで検索品質を測ったあと、コンテキストの組み立てと根拠の判定、コーパスの外の質問の拒否まで、RAGパイプライン一式を完成させます。ピリオドによる分割がどこで不自然に切るかも、自分で確認してみます。