3.7 動手實踐:文件分塊
用純 Python 實作三種分塊方法,並用同一份文件比較它們切出來的結果。
把固定大小、遞迴切分和依標題切分套在同一份文件上,差異會比只看定義清楚得多。以下程式只用 Python 內建功能,不需要另外安裝套件。
測試用的文件
一份很短的員工手冊,Markdown 格式,共 214 個字:
# 員工手冊
## 特別休假
員工在同一公司服務滿六個月未滿一年者,每年有三天特別休假。滿一年未滿兩年者,有七天。滿兩年未滿三年者,有十天。
特別休假的日期,由員工和公司協商安排。年度結束時沒有休完的天數,公司應該折算成工資發給員工。
## 加班規定
加班必須事先經主管同意。平日加班前兩小時,以平日每小時工資的 1.34 倍計算。第三小時起,以 1.67 倍計算。
休息日加班另有規定,不得低於勞動基準法的標準。
完整程式
存成 chunk_demo.py:
DOC = """# 員工手冊
## 特別休假
員工在同一公司服務滿六個月未滿一年者,每年有三天特別休假。滿一年未滿兩年者,有七天。滿兩年未滿三年者,有十天。
特別休假的日期,由員工和公司協商安排。年度結束時沒有休完的天數,公司應該折算成工資發給員工。
## 加班規定
加班必須事先經主管同意。平日加班前兩小時,以平日每小時工資的 1.34 倍計算。第三小時起,以 1.67 倍計算。
休息日加班另有規定,不得低於勞動基準法的標準。
"""
def fixed_chunks(text, size, overlap):
"""固定大小:每 size 個字切一刀,相鄰兩塊重疊 overlap 個字"""
step = size - overlap
chunks = []
for start in range(0, len(text), step):
piece = text[start:start + size]
if piece.strip():
chunks.append(piece)
if start + size >= len(text):
break
return chunks
def split_keep(text, sep):
"""用 sep 切開,但把 sep 留在前一段的結尾"""
parts = text.split(sep)
out = [p + sep for p in parts[:-1]] + [parts[-1]]
return [p for p in out if p]
def recursive_chunks(text, size, seps=("\n\n", "\n", "。", "")):
"""遞迴切分:先試著用大的分隔(段落),太長再用小的(換行、句號),最後才硬切"""
if len(text) <= size:
return [text]
sep, rest = seps[0], seps[1:]
if sep == "":
return [text[i:i + size] for i in range(0, len(text), size)]
chunks, current = [], ""
for part in split_keep(text, sep):
if len(part) > size:
if current:
chunks.append(current)
current = ""
chunks.extend(recursive_chunks(part, size, rest))
elif len(current) + len(part) <= size:
current += part
else:
chunks.append(current)
current = part
if current:
chunks.append(current)
return [c.strip() for c in chunks if c.strip()]
def heading_chunks(text):
"""依 Markdown 標題切:每一節是一塊,並記下它所在的標題路徑"""
chunks, path, body = [], [], []
def flush():
content = "\n".join(body).strip()
if content:
chunks.append({"headings": " > ".join(path), "text": content})
body.clear()
for line in text.splitlines():
if line.startswith("#"):
flush()
level = len(line) - len(line.lstrip("#"))
path[:] = path[: level - 1] + [line.lstrip("# ").strip()]
else:
body.append(line)
flush()
return chunks
if __name__ == "__main__":
print(f"全文共 {len(DOC)} 個字\n")
print("=== 固定大小:size=80, overlap=20 ===")
for i, c in enumerate(fixed_chunks(DOC, 80, 20), 1):
print(f"[{i}] ({len(c)} 字) {c!r}")
print("\n=== 遞迴切分:size=80 ===")
for i, c in enumerate(recursive_chunks(DOC, 80), 1):
print(f"[{i}] ({len(c)} 字) {c!r}")
print("\n=== 依標題切分 ===")
for i, c in enumerate(heading_chunks(DOC), 1):
print(f"[{i}] {c['headings']} ({len(c['text'])} 字)")
執行:
python chunk_demo.py
執行結果
全文共 214 個字
=== 固定大小:size=80, overlap=20 ===
[1] (80 字) '# 員工手冊\n\n## 特別休假\n\n員工在同一公司服務滿六個月未滿一年者,每年有三天特別休假。滿一年未滿兩年者,有七天。滿兩年未滿三年者,有十天。\n\n特別休假的日'
[2] (80 字) '兩年未滿三年者,有十天。\n\n特別休假的日期,由員工和公司協商安排。年度結束時沒有休完的天數,公司應該折算成工資發給員工。\n\n## 加班規定\n\n加班必須事先經主管'
[3] (80 字) '\n\n## 加班規定\n\n加班必須事先經主管同意。平日加班前兩小時,以平日每小時工資的 1.34 倍計算。第三小時起,以 1.67 倍計算。\n\n休息日加班另有規定,'
[4] (34 字) '.67 倍計算。\n\n休息日加班另有規定,不得低於勞動基準法的標準。\n'
=== 遞迴切分:size=80 ===
[1] (72 字) '# 員工手冊\n\n## 特別休假\n\n員工在同一公司服務滿六個月未滿一年者,每年有三天特別休假。滿一年未滿兩年者,有七天。滿兩年未滿三年者,有十天。'
[2] (55 字) '特別休假的日期,由員工和公司協商安排。年度結束時沒有休完的天數,公司應該折算成工資發給員工。\n\n## 加班規定'
[3] (57 字) '加班必須事先經主管同意。平日加班前兩小時,以平日每小時工資的 1.34 倍計算。第三小時起,以 1.67 倍計算。'
[4] (23 字) '休息日加班另有規定,不得低於勞動基準法的標準。'
=== 依標題切分 ===
[1] 員工手冊 > 特別休假 (103 字)
[2] 員工手冊 > 加班規定 (82 字)
三種方法比較
| 固定大小 | 遞迴切分 | 依標題切分 | |
|---|---|---|---|
| 塊數 | 4 | 4 | 2 |
| 句子完整嗎 | 不完整,切在句子中間 | 完整 | 完整 |
| 數字 1.67 被切斷了嗎 | 是 | 否 | 否 |
| 標題和內容對得上嗎 | 否,標題被拆開 | 大致,但第 2 塊尾端多了下一節的標題 | 是,標題是 metadata |
| 每塊大小 | 一致 | 接近上限 | 不一致(103、82) |
| 需要文件有結構嗎 | 不需要 | 不需要 | 需要 |
觀察與思考
- 固定大小最粗糙,但在沒有結構的文字上還是可用。
- 遞迴切分已經大幅改善,大部分一般文字拿它當預設就夠了。
- 依標題切分得到的塊最符合人類的閱讀單位,但前提是文件要有結構,而且每一節的長度要在可接受的範圍。
- 這份文件太短,所以差別看起來不大。實際的文件有幾十頁,差異會更明顯。
- 沒有哪一種永遠最好,要看資料的型態和你的問題。
自己試試看
- 把
size改成 40、120、200,看看切法怎麼變。 - 把
overlap改成 0 和 40,觀察重疊的效果。 - 換成你自己的文件,例如一篇筆記或一份規章,看哪一種切得最自然。
- 試著把依標題切分的結果,如果某一節超過 200 字,再用
recursive_chunks切一次,把兩種方法合起來。 - 把標題路徑加進每一塊的開頭(3.5 提到的技巧),看文字變成怎樣。