3.4 內容感知的分塊方法
順著文件本來的結構來切,讓每一塊都是一個完整的意思。
固定大小分塊不管內容,硬切的結果常常把完整的意思拆開。內容感知(content-aware) 的分塊,則是順著文件自己的結構切:標題、段落、句子。每一塊更可能是一個完整的想法。
方法一:遞迴切分
遞迴切分(recursive splitting)的想法是:先用最大的分隔切,不夠小再用更小的分隔切。
分隔符號依序是:
- 空行(段落)
- 換行
- 句號(句子)
- 最後才是硬切字元
過程是:先試著用段落切,如果某一段仍然超過大小上限,就對那一段改用換行切;還不行就用句號;真的不行才硬切。同時盡量把相鄰的小段落合併,讓塊的大小接近上限,但不超過。
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()]
(split_keep 是把文字依分隔符號切開、但保留分隔符號的小函式,完整程式在 3.7。)
同一份員工手冊,用 size=80 切的結果:
[1] (72 字) '# 員工手冊\n\n## 特別休假\n\n員工在同一公司服務滿六個月未滿一年者,每年有三天特別休假。滿一年未滿兩年者,有七天。滿兩年未滿三年者,有十天。'
[2] (55 字) '特別休假的日期,由員工和公司協商安排。年度結束時沒有休完的天數,公司應該折算成工資發給員工。\n\n## 加班規定'
[3] (57 字) '加班必須事先經主管同意。平日加班前兩小時,以平日每小時工資的 1.34 倍計算。第三小時起,以 1.67 倍計算。'
[4] (23 字) '休息日加班另有規定,不得低於勞動基準法的標準。'
和上一節比,每一塊都是完整的句子,數字 1.67 也沒有被切斷了。
但也不是完美:第 2 塊的結尾帶著「## 加班規定」這個標題,卻不是它下面的內容,因為只看長度,沒有理解標題的意義。這是遞迴切分的限制。
遞迴切分是很實用的預設選擇,LangChain 的 RecursiveCharacterTextSplitter 就是這個做法,很多專案拿它當作起點。
方法二:依文件結構切
如果文件本身有明確的結構,直接依結構切最自然。
- Markdown:依標題(
#、##)切。每一節自成一塊,標題就是這一塊的主題。 - HTML:依
<h1>、<h2>、<section>等標籤切。 - 程式碼:依函式、類別切,而不是依字數。
- 法規、合約:依「條」「項」切。
- FAQ:一個問答一塊。
依標題切分的做法是,順便把標題的路徑記下來:
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
結果:
[1] 員工手冊 > 特別休假 (103 字)
[2] 員工手冊 > 加班規定 (82 字)
兩塊正好對應兩個主題,每一塊都帶著它所屬的標題路徑,這個路徑可以變成 metadata(3.5),也可以放進塊的文字開頭,幫助檢索。
缺點:節的長短不一,有的節很短、有的節很長。太長的節還需要再用遞迴切分切一次。
方法三:語意分塊
語意分塊(semantic chunking) 用嵌入向量來判斷哪裡該切:
- 先把文件切成一個一個句子。
- 算出每個句子的向量。
- 比較相鄰句子的相似度。當相似度突然大幅下降,代表話題換了,就在那裡切開。
優點是切出來的塊主題一致。缺點是要多做很多嵌入運算,成本和複雜度都比較高,效果也不一定贏過簡單的方法,要用自己的資料測試才知道。
方法四:小塊檢索,大塊交給模型
這是個有趣的進階技巧(有時叫 small-to-big 或 parent-child):
- 把文件切成小塊去做檢索,因為小塊的意思集中,找得準。
- 但找到之後,把那一小塊所屬的較大一段(例如整個章節)交給模型,因為模型需要完整的上下文。
這樣同時兼顧了檢索的精準度和上下文的完整性。
怎麼選
| 方法 | 適合 | 備註 |
|---|---|---|
| 固定大小 | 快速原型、沒有結構的文字 | 最簡單,但常切斷意思 |
| 遞迴切分 | 大多數文字,當預設 | 實用、好上手 |
| 依文件結構 | Markdown、HTML、程式碼、法規 | 要文件有明確結構 |
| 語意分塊 | 主題轉換不明顯的長文 | 成本較高,要測試是否值得 |
| 小塊找、大塊給 | 檢索精準度和上下文都重要 | 實作比較複雜 |
一般的建議:先用遞迴切分或依結構切分做出第一版,再用第 6 章的評估方法,確認有需要才換更複雜的方法。