精華筆記

· @aihub.tw

RAG 系統實作

切塊策略:RAG 成敗的隱形關鍵

切塊策略:RAG 成敗的隱形關鍵

你花了幾天選好向量資料庫、串好 embedding API、寫完檢索邏輯——結果問一個明明在文件裡的問題,RAG 給你一個廢話或完全不相關的段落。你懷疑是模型問題,換了 embedding;懷疑是向量庫問題,換了 Qdrant。最後才發現:問題在切塊。

切塊(chunking)是把原始文件切成小段送進向量資料庫的那一步,看起來只是個前處理細節,實際上是整條 RAG pipeline 裡影響最大、最容易忽略的環節。2026 年的實測數據顯示,同一份語料、同一個 embedding 模型、只改切塊策略,檢索召回率的差距可以高達 9%——換句話說,切塊選錯,後面再怎麼調都是枉然。

這堂課適合誰 適合:想自己動手搭 RAG 系統的工程師或技術人員。需要基礎:Python 基本語法(list、dict、for loop)、會安裝 pip 套件;看過第 2 課和第 3 課更好,但不是硬性要求。前置課:第 3 課(向量資料庫:選型與實作)。

這堂學什麼

  • 為什麼不能直接把整份文件塞進去:token 限制與向量語意稀釋問題
  • Chunk size 與 overlap 的實際取捨,以及為什麼「預設值」不一定適合你的語料
  • 三種切法比較:固定長度、按結構切、語意切塊各自的適用場景與代價
  • Metadata 設計:來源、章節、位置資訊怎麼存,讓後續過濾更精準
  • 表格和程式碼的特殊處理,防止切壞格式
  • 實戰:同一份語料三種切法效果對比,看數字說話

觀念一:為什麼一定要切

先確認一件事:第 1 課提過「長 context 不是萬靈丹」,但這堂課要講的不是模型 context 上限,而是向量語意的問題。

把一份 50 頁的 PDF 直接送進 embedding 模型,得到的是這整份文件的語意向量——它代表這份文件「整體」在說什麼,而不是你要查的那個段落在說什麼。搜尋時你用一個具體問題去比對,卻拿到一個模糊的整體摘要,自然很難精準命中。

為什麼要切塊:整文件 vs 小段向量的比對精準度

切塊的核心邏輯是:讓每一個向量只負責表達一個「最小語意單位」,這樣 query 向量才能精準找到它。太大,語意稀釋;太小,語意破碎——這是後面所有切塊策略要解決的根本矛盾。

觀念二:Chunk size 與 overlap 的實際取捨

Chunk size

Chunk size 通常以 token 計(不是字元)。一個中文字大約對應 1–2 個 token,一個英文單字約 1–1.5 個 token,所以不要用字元數來對應。

2026 年現況的建議起始點是 256–512 tokens。以下是常見的取捨:

Chunk size 優點 缺點
128 以下 語意集中,比對精準 容易切斷完整句子,context 不夠
256–512 大多數場景的甜蜜點 需要依語料調整
1024 以上 保留更多上下文 語意稀釋,比對精準度下降

什麼時候往大調? 你的 query 通常是長句或需要跨段落的理解,例如法律條文、技術規格書。

什麼時候往小調? 你的文件有大量密集知識點,每句話都是獨立事實,例如 FAQ、詞彙表、商品規格表。

Overlap

Overlap 是兩個相鄰 chunk 之間重疊的 token 數,目的是避免一個語意完整的句子被切成兩半、分別掉進不同 chunk 裡。

業界常見做法是 chunk size 的 10–20%,比如 512 tokens 配 50–100 tokens 的 overlap。但有個重要的 caveat:2026 年 1 月一份用 SPLADE 檢索搭配 Mistral-8B 的系統性分析發現,overlap 對召回率沒有可量測的提升,反而增加了索引成本和儲存空間。

結論是:overlap 預設開 10% 沒問題,但不要假設它一定有效。把 overlap=0 和 overlap=10% 各跑一次評測,看數字決定。

Chunk size 與 overlap 的視覺說明

觀念三:三種切法的比較

方法一:固定長度切(Fixed-size Chunking)

最直接。用 RecursiveCharacterTextSplitter 指定 token 數,遇到 \n\n\n、句號等分隔符依序切,切到夠小為止。

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,        # tokens
    chunk_overlap=64,
    length_function=len,   # 改成 token 計數見下方說明
    separators=["\n\n", "\n", "。", ".", " ", ""],
)

chunks = splitter.create_documents([raw_text])
print(f"共切出 {len(chunks)} 個 chunk")
print(chunks[0].page_content[:200])

適用:通用文章、純文字語料、快速原型。

弱點:完全不看文件結構,一個段落可能被切成兩半,一個標題和正文可能被切進不同 chunk。

注意:length_function=len 算的是字元數不是 token 數。若要精準按 token 計,換成 tiktoken 或 HuggingFace tokenizer 的計數函式:

import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
length_function = lambda text: len(enc.encode(text))

方法二:按結構切(Structure-aware Chunking)

根據文件本身的結構邊界切——Markdown 標題、HTML <h2>、段落換行、條列清單的縮排層級等。

from langchain.text_splitter import MarkdownHeaderTextSplitter

headers_to_split_on = [
    ("#", "h1"),
    ("##", "h2"),
    ("###", "h3"),
]

md_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers_to_split_on,
    strip_headers=False,   # 保留標題在 chunk 內,讓 embedding 帶有標題語意
)

chunks = md_splitter.split_text(markdown_text)
for c in chunks:
    print(c.metadata)   # {'h1': '安裝指南', 'h2': '環境需求'}
    print(c.page_content[:100])
    print("---")

切出來的每個 chunk 的 metadata 會自動帶有所在的標題層級,後面可以用來過濾(「只找第二章的內容」)。如果某個章節本身太長,還可以把 MarkdownHeaderTextSplitter 的結果再送進 RecursiveCharacterTextSplitter 做二次切割。

適用:Markdown 技術文件、API 文件、結構清晰的報告。

弱點:文件必須有清楚的結構標記;掃描版 PDF 或純文字文件沒有標題標記就沒用。

方法三:語意切塊(Semantic Chunking)

用 embedding 本身判斷「語意邊界」——相鄰兩句話的語意相似度低於閾值,就在那裡切。

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

splitter = SemanticChunker(
    embeddings,
    breakpoint_threshold_type="percentile",   # 依語意落差的百分位數決定切點
    breakpoint_threshold_amount=95,           # 語意落差在前 5% 的地方切
)

chunks = splitter.create_documents([raw_text])
print(f"共切出 {len(chunks)} 個 chunk,平均長度 {sum(len(c.page_content) for c in chunks)//len(chunks)} 字")

代價:語意切塊需要對每一句話先跑 embedding 來判斷切點,速度大約是 token-based 切法的 1/14。一份 1 萬字的文件,光是切塊就可能產生幾百次 embedding API 呼叫,成本和時間都要算進去。

2026 年 2 月的測試:對 50 篇學術論文跑評測,遞迴 512-token 切法達到 69% 準確率,語意切塊反而只有 54%(原因是切出的片段平均才 43 個 token,太破碎)。

什麼時候值得用? 語料沒有明顯結構(例如大量連續散文),而且你有辦法跑評測確認它對你的資料集確實有效。

三種切塊方式的對照圖

觀念四:Metadata 設計

切完的 chunk 要存進向量資料庫時,光存 page_content 還不夠。Metadata 是讓你之後能夠過濾、引用、溯源的關鍵欄位。

from langchain.schema import Document

def build_document(chunk_text, source_file, chapter, section, chunk_index, total_chunks):
    return Document(
        page_content=chunk_text,
        metadata={
            "source": source_file,          # "product-manual-v3.pdf"
            "chapter": chapter,             # "第三章"
            "section": section,             # "2.4 安裝步驟"
            "chunk_index": chunk_index,     # 0, 1, 2, ...
            "total_chunks": total_chunks,   # 這份文件共切幾塊
            "char_count": len(chunk_text),  # 字元數,方便 debug
        }
    )

哪些欄位特別重要?

  • source:告訴 LLM 這段話來自哪個文件,也讓你在 UI 上顯示引用來源。
  • chapter / section:讓你可以在搜尋時加過濾條件,例如「只找第二章的 chunk」。
  • chunk_index + total_chunks:如果使用者問的問題橫跨多段,你可以根據這兩個欄位去抓鄰近的 chunk 補完 context(Parent-Child Retrieval 技巧,第 5 課會展開)。

Metadata 的設計沒有固定格式,但有一個原則:你之後想過濾什麼條件,現在就要存什麼欄位。事後補 metadata 等於重跑整條 indexing pipeline。

觀念五:特殊格式的處理

程式碼區塊

程式碼最忌諱被切一半。下面的做法是先把程式碼區塊整塊抽出來,作為獨立 chunk 保留,其餘文字再正常切:

import re
from langchain.schema import Document

def split_with_code_preservation(text, source, chunk_size=512, chunk_overlap=64):
    """把 code block 整塊保留,其餘文字用 RecursiveCharacterTextSplitter 切"""
    from langchain.text_splitter import RecursiveCharacterTextSplitter

    # 找出所有 ```...``` 區塊的位置
    code_pattern = re.compile(r'(```[\s\S]*?```)', re.MULTILINE)
    parts = code_pattern.split(text)

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
    )

    docs = []
    for i, part in enumerate(parts):
        if part.startswith("```") and part.endswith("```"):
            # 程式碼區塊:整塊當一個 chunk,加上 is_code 標記
            docs.append(Document(
                page_content=part,
                metadata={"source": source, "is_code": True, "part_index": i}
            ))
        else:
            # 一般文字:正常切
            sub_docs = splitter.create_documents([part])
            for d in sub_docs:
                d.metadata.update({"source": source, "is_code": False, "part_index": i})
            docs.extend(sub_docs)

    return docs

表格

表格用固定長度切法幾乎必爛——切到一半的 CSV 或 Markdown 表格,embedding 根本不知道那些欄名跟數字是什麼關係。常見做法有兩種:

做法 A:把整張表格轉成自然語言再 embed

def table_to_text(df, table_title=""):
    """把 pandas DataFrame 轉成自然語言描述"""
    lines = []
    if table_title:
        lines.append(f"以下是「{table_title}」的資料:")
    for _, row in df.iterrows():
        # 每一行轉成一句話
        desc = "、".join([f"{col}為{row[col]}" for col in df.columns])
        lines.append(desc)
    return "\n".join(lines)

做法 B:整張表格當一個 chunk,metadata 標明 is_table: True

如果表格不大(< 512 tokens),整張存進去,搜尋時可以用 is_table 過濾讓 LLM 看原始表格。

程式碼與表格的特殊切塊處理流程

手把手實戰:同語料三種切法效果對比

這一節用同一份技術文件(Markdown 格式,約 3,000 字)跑三種切法,分別索引後問同一個問題,看哪種召回的 chunk 最相關。

環境準備:

pip install langchain langchain-openai langchain-experimental chromadb tiktoken

準備語料與評測問題

先準備一份有結構的 Markdown 文件當語料。如果你手邊沒有,可以用下面的腳本下載一份 LangChain 官方文件:

# prepare_corpus.py
import urllib.request

url = "https://raw.githubusercontent.com/langchain-ai/langchain/master/README.md"
urllib.request.urlretrieve(url, "corpus.md")

with open("corpus.md", "r") as f:
    raw_text = f.read()

print(f"語料長度:{len(raw_text)} 字元")

定義評測問題:

TEST_QUERIES = [
    "LangChain 支援哪些向量資料庫?",
    "如何快速開始使用 LangChain?",
    "LangChain 的授權條款是什麼?",
]

方法一:固定長度切(512 tokens)

# method1_fixed.py
import os
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

os.environ["OPENAI_API_KEY"] = "sk-..."   # 換成你的 key

with open("corpus.md") as f:
    raw_text = f.read()

# 切塊
splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=64,
    separators=["\n\n", "\n", ".", " ", ""],
)
docs_fixed = splitter.create_documents(
    [raw_text],
    metadatas=[{"source": "corpus.md", "method": "fixed"}]
)
print(f"[固定長度] 切出 {len(docs_fixed)} 個 chunk")

# 索引
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
db_fixed = Chroma.from_documents(
    docs_fixed,
    embeddings,
    collection_name="fixed_chunks",
    persist_directory="./chroma_fixed",
)

方法二:按結構切(Markdown 標題)

# method2_structure.py
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter

headers_to_split_on = [("#", "h1"), ("##", "h2"), ("###", "h3")]
md_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers_to_split_on,
    strip_headers=False,
)
header_chunks = md_splitter.split_text(raw_text)

# 若某個章節還是太長,二次切割
char_splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=64)
docs_structure = char_splitter.split_documents(header_chunks)

# 補上 method 標記
for d in docs_structure:
    d.metadata["source"] = "corpus.md"
    d.metadata["method"] = "structure"

print(f"[按結構切] 切出 {len(docs_structure)} 個 chunk")
print("範例 metadata:", docs_structure[0].metadata)

db_structure = Chroma.from_documents(
    docs_structure,
    embeddings,
    collection_name="structure_chunks",
    persist_directory="./chroma_structure",
)

方法三:語意切塊

# method3_semantic.py
from langchain_experimental.text_splitter import SemanticChunker

semantic_splitter = SemanticChunker(
    embeddings,
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=95,
)
docs_semantic = semantic_splitter.create_documents([raw_text])
for d in docs_semantic:
    d.metadata.update({"source": "corpus.md", "method": "semantic"})

print(f"[語意切塊] 切出 {len(docs_semantic)} 個 chunk")
avg_len = sum(len(d.page_content) for d in docs_semantic) // len(docs_semantic)
print(f"平均 chunk 長度:{avg_len} 字元")

db_semantic = Chroma.from_documents(
    docs_semantic,
    embeddings,
    collection_name="semantic_chunks",
    persist_directory="./chroma_semantic",
)

比較三種切法的召回結果

# compare.py
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# 載入已索引的三個 collection
db_fixed = Chroma(
    collection_name="fixed_chunks",
    embedding_function=embeddings,
    persist_directory="./chroma_fixed",
)
db_structure = Chroma(
    collection_name="structure_chunks",
    embedding_function=embeddings,
    persist_directory="./chroma_structure",
)
db_semantic = Chroma(
    collection_name="semantic_chunks",
    embedding_function=embeddings,
    persist_directory="./chroma_semantic",
)

TEST_QUERIES = [
    "LangChain 支援哪些向量資料庫?",
    "如何快速開始使用 LangChain?",
]

for query in TEST_QUERIES:
    print(f"\n=== Query: {query} ===")
    for label, db in [("固定長度", db_fixed), ("按結構切", db_structure), ("語意切塊", db_semantic)]:
        results = db.similarity_search_with_score(query, k=3)
        print(f"\n[{label}] Top 3 結果:")
        for doc, score in results:
            print(f"  相似度分數: {score:.4f}")
            print(f"  Metadata: {doc.metadata}")
            print(f"  內容前 80 字: {doc.page_content[:80].replace(chr(10), ' ')}")

解讀輸出:分數越低代表距離越近(Chroma 預設用 L2 距離)。比較三種切法各自的 Top 1 分數,以及 metadata 裡的標題資訊是否能告訴你「這段話來自文件哪裡」。

三種切法的召回結果對比圖

常見坑

坑 1:Chunk size 用字元數設定,但 embedding 模型按 token 限制

症狀:切出來的 chunk 送進 embedding API 時偶爾報錯:

openai.BadRequestError: This model's maximum context length is 8192 tokens.
However, your messages resulted in 9043 tokens.

原因是你用 len(text) 算字元數設 chunk_size=2000,但一個英文單字可能 1–2 個 token,一個 CJK 字元是 1–3 個 token,字元數和 token 數差很多。解法:換成 token-accurate 的 length_function:

import tiktoken

enc = tiktoken.get_encoding("cl100k_base")  # OpenAI embedding 用的 tokenizer

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=64,
    length_function=lambda text: len(enc.encode(text)),  # 真正用 token 計數
)

坑 2:切出空 chunk 或只有換行的 chunk,進了向量庫變垃圾資料

症狀:檢索召回的 chunk 內容是空字串或只有空白,導致 LLM 生成無意義的回答。排查方式:

# 索引前過濾掉太短的 chunk
MIN_CHUNK_CHARS = 50

cleaned_docs = [d for d in docs if len(d.page_content.strip()) >= MIN_CHUNK_CHARS]
print(f"過濾前:{len(docs)} 個 chunk → 過濾後:{len(cleaned_docs)} 個 chunk")

常見原因:PDF 轉文字後有大量空白頁、Markdown 文件有連續多行空行、表格切壞後只剩分隔符號。加上這個過濾步驟是標準防護。

坑 3:Metadata 沒存 source,最後 LLM 無法引用來源

症狀:RAG 給出正確答案,但使用者問「這個資料從哪來?」LLM 回答「根據提供的資料」,完全無法追溯。這個問題在切塊時就已經埋下了——如果 Documentmetadata 裡沒有 source 欄位,之後再要補已經索引進去的資料非常麻煩。

解法:切塊時強制加 source,最好連檔案路徑和最後修改時間都存進去:

import os

def chunk_file(filepath, splitter):
    with open(filepath) as f:
        text = f.read()
    docs = splitter.create_documents([text])
    for d in docs:
        d.metadata["source"] = os.path.basename(filepath)
        d.metadata["filepath"] = filepath
        d.metadata["mtime"] = os.path.getmtime(filepath)
    return docs

坑 4:語意切塊切出超短 chunk,反而讓 embedding 失去語意

症狀:用 SemanticChunker 切出來的某些 chunk 只有 10–20 個字,送進 embedding 後的向量幾乎沒有語意資訊,檢索時頻繁召回這些無意義短片段。

解法:對 SemanticChunker 的輸出加最小長度過濾,或調低 breakpoint_threshold_amount(讓切點更保守):

# 調整切點敏感度:從 95 降到 90,切得少一點、每塊長一點
splitter = SemanticChunker(
    embeddings,
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=90,  # 降低敏感度
    min_chunk_size=100,              # 最小 chunk 字元數(部分版本支援)
)

# 過濾太短的 chunk
docs_semantic = [d for d in docs_semantic if len(d.page_content.strip()) >= 100]

作業

  1. 找一份你實際工作中會用到的文件(公司 SOP、產品說明書、技術規格、電商商品描述都可以),跑完整的三種切法對比,記下各種切法切出的 chunk 數量和平均長度。
  2. 設計至少 5 個測試問題,涵蓋「問事實」、「問步驟」、「問比較」三種類型,用這 5 個問題評測三種切法哪種召回品質最好。
  3. 進階題:在 Metadata 裡加上 chunk_indextotal_chunks,改寫 compare.py,讓它在召回 Top 1 之後,自動把相鄰的前後各一個 chunk 一起拿回來——體驗一下 Parent-Child Retrieval 的效果。

下一課預告

切塊切得好只解決了「文件能被正確 embed」的問題,但檢索本身還有很多可以優化的地方。第 5 課的主題是檢索品質:Hybrid Search(向量搜尋 + 關鍵字搜尋的混合)和 Rerank(用更強的模型對初步召回結果二次排序)。這兩個技術組合起來,可以大幅提升召回的精準度——尤其對那些包含專有名詞、縮寫、產品型號的技術文件,效果特別明顯。你的切塊工作在這堂結束,下一堂讓這些 chunk 被更準確地找到。

#RAG#切塊策略#Chunking#向量資料庫#LangChain

← 回所有文章