文件切分
用 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 課的評估來定:換幾種大小各跑一遍評估集,看哪種檢索效果最好。
重疊要不要
固定長度切分時,重疊能防止一句話被切成兩半後兩邊都不完整。按結構切分時,邊界本來就落在段落或者標題上,一般不需要重疊。
重疊也有代價:同樣的內容出現在兩個塊裡,檢索時可能兩塊一起被取回來,佔用了寶貴的位置。
練習
- 把
split_fixed的size改成 300 和 2000,看看timeouts.md被切成了什麼樣。哪種你覺得更合適? - 修改
split_by_heading_capped,把只有標題、沒有內容的塊(比如短於 50 個字元的)和它後面的塊合併。 - 找一份你自己的文件(專案的 README、公司的 Wiki 匯出、一個 PDF 轉成的文本),用這三種方法切一遍,逐塊看看有沒有被切壞的地方。
自測
1. 按固定長度切分,最大的問題是什麼?
它不理會文件的結構,經常在句子、程式碼、網址的中間切斷,一塊裡還可能混著兩個不相關的小節。這樣的塊意思不完整、不單一,檢索和回答的效果都會變差。
2. 按 Markdown 標題切分時,為什麼要特別處理程式碼塊?
程式碼塊裡的 Python 註釋以 # 加空格開頭,和 Markdown 標題的寫法一模一樣。不處理的話,註釋會被當成標題,程式碼塊被從中間切開,說明文字和程式碼示例被分到不同的塊裡。
3. 為什麼限制塊的長度時,要在每一小塊前面補上所屬的標題?
長的一節被切成幾小塊後,後面的小塊可能只有正文,看不出在講什麼。補上標題,每一小塊就帶上了主題資訊,檢索時更容易被正確匹配,放進提示詞時模型也知道這段內容的上下文。
提問與討論
這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。
提問 +3 點,回答別人 +6 點。內容經審核後公開。
正在載入討論…