モジュール 04 · 第 2 回

ドキュメントの分割

httpx のドキュメントで三つの分割方法を実際に比べます。固定長、見出しごと、見出しごと+長さの上限。さらに本物の落とし穴にも出会います。コードブロックの中のコメントが見出しとして扱われてしまうのです。

  • 約 35 分
  • 難易度:中級
  • 検証:2026-09-14、純粋な Python、API 呼び出しなし

コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。

RAG が検索する単位はドキュメント全体ではなく、ドキュメントを分割した小さな塊(チャンク)です。タイムアウトについてのページにはいくつも節があるかもしれず、ユーザーが「タイムアウトを無効にするには」と聞いたとき、ほしいのはタイムアウトの無効化を説明した短い一節で、ページ全体ではありません。

どう分割するかは小さな技術的細部に見えますが、実は RAG の効果を大きく左右します。チャンクが大きすぎると、検索のときに関係する内容が大量の無関係な内容に薄められます。小さすぎると、ひとまとまりの意味がばらばらになり、取り出せるのは文の半分だけになります。この課では httpx の本物のドキュメントを使って、いくつかの分割方法を比べます。

方法一:固定長で分割する

最も簡単な分割方法です。内容に関係なく、800 文字ごとに切ります。文をちょうど真っ二つにしてしまわないよう、隣り合うチャンクを 100 文字重ねます。

def split_fixed(text, size=800, overlap=100):
    """办法一:每 size 个字符切一刀,相邻两块重叠 overlap 个字符。"""
    chunks, start = [], 0
    while start < len(text):
        chunks.append(text[start:start + size])
        start += size - overlap
    return chunks

タイムアウトについての timeouts.md がどう分割されたか見てみましょう。これは 2 番目のチャンクです。

le.com/api/v1/example", timeout=None)
```

## Setting a default timeout on a client

You can set a timeout on a client instance, which results in the given
`timeout` being used as the default for requests made with this client:

```python
client = httpx.Client()              # Use a default 5s timeout everywhere.
client = httpx.Client(timeout=10.0)  # Use a default 10s timeout everywhere.
client = httpx.Client(timeout=None)  # Disable all timeouts by default.
```

## Fine tuning the configuration

HTTPX also allows you to specify the timeout behavior in more fine grained detail.

There are four different types of timeouts that may occur. These are **connect**,
**read**, **write**, and **pool** timeouts.

* The **connect** timeout specifies the maximum amount of time to wait until
a socket 

冒頭は途中で切れた URL le.com/api/v1/example" で、末尾は文の途中の「until a socket」で止まっています。間には二つの小節がまたがっていて、半分はクライアントの既定のタイムアウト、半分は四種類のタイムアウトについてです。このチャンクの意味は入り混じっています。ユーザーが「四種類のタイムアウトはそれぞれ何か」と聞けば、このチャンクには答えの半分しかありません。「クライアントに既定のタイムアウトを設定するには」と聞けば、このチャンクは無関係な内容をたくさん連れてきます。

固定長の分割は簡単で、どんなテキストにも使え、チャンクの長さもそろいます。しかしドキュメントの構造はまったく気にかけません。

方法二:見出しごとに分割する

httpx のドキュメントは Markdown で、もともと ##### の見出しで節に分かれています。一つの節は一つのことを扱っているので、自然でよい分割単位です。そこで見出しごとに分割します。見出しに出会ったら、新しいチャンクを始めるのです。

最も直接的な書き方は正規表現で、# で始まるすべての行の前で切ります。

def split_by_heading_naive(text):
    """办法二(有问题的版本):凡是以 # 开头的行都当成标题切开。"""
    parts = re.split(r"(?m)^(?=#{1,3} )", text)
    return [p.strip() for p in parts if p.strip()]

timeouts.md がどんなチャンクに分割されたか、各チャンクの最初の行だけを表示して見てみましょう。

有问题的按标题切法,timeouts.md 的各块开头:
  [ 153 字符] HTTPX is careful to enforce timeouts everywhere by default.
  [  93 字符] ## Setting and disabling timeouts
  [  87 字符] # Using the top-level API:
  [ 186 字符] # Using a client instance:
  [  87 字符] # Using the top-level API:
  [ 127 字符] # Using a client instance:
  [ 424 字符] ## Setting a default timeout on a client
  [1386 字符] ## Fine tuning the configuration
  [ 207 字符] # A client with a 60s timeout for connecting, and a 10s timeout elsewhere.

# Using the top-level API: は見出しではなく、コードブロックの中の Python のコメントです。Python のコメントと Markdown のレベル 1 見出しは、どちらも # と空白という、まったく同じ見た目をしています。そのためコードブロックが途中で切られ、「Setting and disabling timeouts」の節には 93 文字の説明文しか残らず、コード例はすべて別のチャンクに行ってしまいました。

この種の誤りはとても見つけにくいものです。プログラムはエラーを出さず、分割されたチャンクの数ももっともらしく見え、一つずつ見て初めて気づきます。ドキュメント全体では、この問題のある版は 236 個のチャンクを作り、正しい版より 60 個多くなりました。増えた分はすべて、こうして細切れにされたコードです。

直し方は、今コードブロックの中にいるかどうかを覚えておき(``` に出会うたびに状態を切り替える)、コードブロックの中の # は見出しとみなさないことです。

def split_by_heading(text):
    """办法二(修正版):同样按标题切,但跳过代码块里以 # 开头的注释行。"""
    chunks, current, in_code = [], [], False
    for line in text.splitlines():
        if line.startswith("```"):
            in_code = not in_code
        if not in_code and re.match(r"#{1,3} ", line) and current:
            chunks.append("\n".join(current).strip())
            current = []
        current.append(line)
    if current:
        chunks.append("\n".join(current).strip())
    return [c for c in chunks if c]

直した後です。

修正后的按标题切法,timeouts.md 的各块开头:
  [ 153 字符] HTTPX is careful to enforce timeouts everywhere by default.
  [ 586 字符] ## Setting and disabling timeouts
  [ 424 字符] ## Setting a default timeout on a client
  [1594 字符] ## Fine tuning the configuration

四つのチャンクで、それぞれが完全な一つの節になっており、説明文とコード例が一緒になっています。

この落とし穴が教えてくれるのは、分割したら必ずいくつかのチャンクを表示して見てみること、です。ドキュメントの形式ごとに落とし穴があります。HTML にはナビゲーションバーやフッター、PDF にはヘッダーやページ番号や途中で切れた表、コードには関数の境界があります。

方法三:見出しごとに分割し、さらに長さを制限する

見出しごとの分割にも問題があります。とても長い節があるのです。httpx のドキュメント全体で、見出しごとに分割した最長のチャンクは 5530 文字あります。チャンクが長すぎると中身が雑多になって検索の効果が落ちますし、モジュール 01 第 5 課で説明したように、bge-small-zh-v1.5 のような埋め込みモデルは最大 512 トークンしか読まず、超えた部分はそのまま捨てられます。

そこでもう一ステップ加えます。上限を超えたチャンクは空行(つまり段落)でさらに分割し、分割した各断片の前にそれが属する見出しを付け加えます。こうすれば、どの断片も自分が何について書かれたものかがわかります。

def split_by_heading_capped(text, max_size=1500):
    """办法三:先按标题切;太长的块再按空行(段落)切开,并在每一小块前面补上所属的标题。"""
    chunks = []
    for section in split_by_heading(text):
        if len(section) <= max_size:
            chunks.append(section)
            continue
        title = section.splitlines()[0] if section.startswith("#") else ""
        current = ""
        for para in section.split("\n\n"):
            if current and len(current) + len(para) > max_size:
                chunks.append(current.strip())
                current = title + "\n\n" if title else ""
            current += para + "\n\n"
        if current.strip():
            chunks.append(current.strip())
    return chunks

比べてみる

httpx のドキュメント全体での四つの分割方法の統計です(完全なコードは code/04-rag/chunking.py。API を呼ばないので、実行すればまったく同じ数字が得られるはずです)。

固定长度:179 块,平均 739 字符,最短 12,最长 800
按标题(有问题):236 块,平均 493 字符,最短 8,最长 5530
按标题(修正):176 块,平均 662 字符,最短 8,最长 5530
按标题+限长:196 块,平均 596 字符,最短 8,最长 2193

長さを制限すると、最長のチャンクは 5530 から 2193 に下がりました。それでも 1500 の上限を超えているのは、このチャンクに空行のない長いコードブロックがあり、私のコードは空行でしか切らず、コードブロックを分割しないからです。これは意図的なトレードオフです。コードを真っ二つにするくらいなら、チャンクが少し長いほうがましです。

最短のチャンクはわずか 8 文字で、見出しだけで中身のない節です。検索の価値はほとんどないので、実際のプロジェクトでは次のチャンクと結合するか、そのまま捨ててかまいません。

この後の課では、どれも「見出しごと+長さの制限」の分割方法を使います。

チャンクの大きさはどう決めるか

決まった答えはありませんが、目安がいくつかあります。

  • 埋め込みモデルの上限を超えない。bge-small-zh と multilingual-e5-small はどちらも 512 トークンです。英語ではおよそ 1 トークンが 4 文字に相当するので、1500 文字は約 400 トークンで、上限の範囲内です。
  • 一つのチャンクは一つのことだけを扱うのが望ましい。ドキュメント自体の構造で分割するほうが、長さで分割するよりこれを実現しやすいです。
  • 検索した後にどう使うかを考える。一度に 5 つのチャンクを取り出してプロンプトに入れ、各チャンクが 600 文字なら合計 3000 文字、約 750 トークンで、多くはありません。各チャンクが 5000 文字なら、5 つで 1 万トークンを超えます。

最終的なチャンクの大きさは、第 6 課の評価で決めるべきです。いくつかの大きさでそれぞれ評価セットを実行し、どれが最もよく検索できるかを見るのです。

重なりは必要か

固定長で分割するとき、重なりは文が真っ二つにされて両側とも不完全になるのを防ぎます。構造で分割するときは、境界がもともと段落や見出しに来るので、ふつう重なりは不要です。

重なりにも代償があります。同じ内容が二つのチャンクに現れるので、検索のときに両方が一緒に取り出され、貴重な枠を占めてしまうかもしれません。

練習問題

  1. split_fixedsize を 300 と 2000 に変えて、timeouts.md がどう分割されるか見てください。どちらが適切だと思いますか。
  2. split_by_heading_capped を修正して、見出しだけで中身のないチャンク(たとえば 50 文字未満のもの)を、その後ろのチャンクと結合するようにしてください。
  3. 自分のドキュメント(プロジェクトの README、社内 Wiki のエクスポート、PDF から変換したテキスト)を一つ用意し、三つの方法で分割して、壊れた切り方をされたところがないか一つずつ見てください。

確認テスト

1. 固定長で分割することの最大の問題は何ですか?

ドキュメントの構造を気にかけず、文、コード、URL の途中でよく切ってしまい、一つのチャンクに無関係な二つの小節が混ざることもあります。そうしたチャンクは意味が不完全で一つにまとまっておらず、検索と回答の効果がどちらも悪くなります。

2. Markdown の見出しで分割するとき、コードブロックを特別に扱う必要があるのはなぜですか?

コードブロックの中の Python のコメントは # と空白で始まり、Markdown の見出しの書き方とまったく同じです。特別に扱わないとコメントが見出しとみなされ、コードブロックが途中で切られて、説明文とコード例が別のチャンクに分かれてしまいます。

3. チャンクの長さを制限するとき、分割した各断片の前に所属する見出しを付け加えるのはなぜですか?

長い節をいくつかの断片に分割すると、後ろの断片には本文しかなく、何について書かれているのかわからなくなることがあります。見出しを付け加えれば各断片が主題の情報を持つので、検索のときに正しくマッチしやすくなり、プロンプトに入れたときにもモデルがその内容の文脈を理解できます。

質問と議論

このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。

質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。

議論を読み込んでいます…