模組 04 · 第 2 課

文件切分

用 httpx 的文件實際比較三種切分方法:按固定長度、按標題、按標題並限制長度。還會遇到一個真實的坑:程式碼塊裡的註釋被當成了標題。

  • 約 35 分鐘
  • 難度:進階
  • 實測:2026-09-14,純 Python,不呼叫 API

程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。

RAG 檢索的單位不是整篇文件,而是文件切出來的小塊(chunk)。一篇講超時的文件可能有好幾節,使用者問"怎麼關閉超時",你只想取回講關閉超時的那一小段,而不是整篇。

怎麼切,看起來是個很小的技術細節,實際上對 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 

開頭是一個被切斷的網址 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 的一級標題長得一模一樣,都是 # 加空格。於是程式碼塊被從中間切開了,"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 塊就是一萬多個詞元了。

最終的塊大小要靠第 6 課的評估來定:換幾種大小各跑一遍評估集,看哪種檢索效果最好。

重疊要不要

固定長度切分時,重疊能防止一句話被切成兩半後兩邊都不完整。按結構切分時,邊界本來就落在段落或者標題上,一般不需要重疊。

重疊也有代價:同樣的內容出現在兩個塊裡,檢索時可能兩塊一起被取回來,佔用了寶貴的位置。

練習

  1. split_fixedsize 改成 300 和 2000,看看 timeouts.md 被切成了什麼樣。哪種你覺得更合適?
  2. 修改 split_by_heading_capped,把只有標題、沒有內容的塊(比如短於 50 個字元的)和它後面的塊合併。
  3. 找一份你自己的文件(專案的 README、公司的 Wiki 匯出、一個 PDF 轉成的文本),用這三種方法切一遍,逐塊看看有沒有被切壞的地方。

自測

1. 按固定長度切分,最大的問題是什麼?

它不理會文件的結構,經常在句子、程式碼、網址的中間切斷,一塊裡還可能混著兩個不相關的小節。這樣的塊意思不完整、不單一,檢索和回答的效果都會變差。

2. 按 Markdown 標題切分時,為什麼要特別處理程式碼塊?

程式碼塊裡的 Python 註釋以 # 加空格開頭,和 Markdown 標題的寫法一模一樣。不處理的話,註釋會被當成標題,程式碼塊被從中間切開,說明文字和程式碼示例被分到不同的塊裡。

3. 為什麼限制塊的長度時,要在每一小塊前面補上所屬的標題?

長的一節被切成幾小塊後,後面的小塊可能只有正文,看不出在講什麼。補上標題,每一小塊就帶上了主題資訊,檢索時更容易被正確匹配,放進提示詞時模型也知道這段內容的上下文。

提問與討論

這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。

提問 +3 點,回答別人 +6 點。內容經審核後公開。

正在載入討論…