EN

3.4 內容感知的分塊方法

順著文件本來的結構來切,讓每一塊都是一個完整的意思。

RAG下一篇 →

固定大小分塊不管內容,硬切的結果常常把完整的意思拆開。內容感知(content-aware) 的分塊,則是順著文件自己的結構切:標題、段落、句子。每一塊更可能是一個完整的想法。

方法一:遞迴切分

遞迴切分(recursive splitting)的想法是:先用最大的分隔切,不夠小再用更小的分隔切。

分隔符號依序是:

  1. 空行(段落)
  2. 換行
  3. 句號(句子)
  4. 最後才是硬切字元

過程是:先試著用段落切,如果某一段仍然超過大小上限,就對那一段改用換行切;還不行就用句號;真的不行才硬切。同時盡量把相鄰的小段落合併,讓塊的大小接近上限,但不超過。

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) 用嵌入向量來判斷哪裡該切:

  1. 先把文件切成一個一個句子。
  2. 算出每個句子的向量。
  3. 比較相鄰句子的相似度。當相似度突然大幅下降,代表話題換了,就在那裡切開。

優點是切出來的塊主題一致。缺點是要多做很多嵌入運算,成本和複雜度都比較高,效果也不一定贏過簡單的方法,要用自己的資料測試才知道。

方法四:小塊檢索,大塊交給模型

這是個有趣的進階技巧(有時叫 small-to-big 或 parent-child):

  • 把文件切成小塊去做檢索,因為小塊的意思集中,找得準。
  • 但找到之後,把那一小塊所屬的較大一段(例如整個章節)交給模型,因為模型需要完整的上下文。

這樣同時兼顧了檢索的精準度和上下文的完整性。

怎麼選

方法 適合 備註
固定大小 快速原型、沒有結構的文字 最簡單,但常切斷意思
遞迴切分 大多數文字,當預設 實用、好上手
依文件結構 Markdown、HTML、程式碼、法規 要文件有明確結構
語意分塊 主題轉換不明顯的長文 成本較高,要測試是否值得
小塊找、大塊給 檢索精準度和上下文都重要 實作比較複雜

一般的建議:先用遞迴切分或依結構切分做出第一版,再用第 6 章的評估方法,確認有需要才換更複雜的方法。

← 全部文章