切塊策略:RAG 成敗的隱形關鍵
你花了幾天選好向量資料庫、串好 embedding API、寫完檢索邏輯——結果問一個明明在文件裡的問題,RAG 給你一個廢話或完全不相關的段落。你懷疑是模型問題,換了 embedding;懷疑是向量庫問題,換了 Qdrant。最後才發現:問題在切塊。
切塊(chunking)是把原始文件切成小段送進向量資料庫的那一步,看起來只是個前處理細節,實際上是整條 RAG pipeline 裡影響最大、最容易忽略的環節。2026 年的實測數據顯示,同一份語料、同一個 embedding 模型、只改切塊策略,檢索召回率的差距可以高達 9%——換句話說,切塊選錯,後面再怎麼調都是枉然。
這堂學什麼
- 為什麼不能直接把整份文件塞進去:token 限制與向量語意稀釋問題
- Chunk size 與 overlap 的實際取捨,以及為什麼「預設值」不一定適合你的語料
- 三種切法比較:固定長度、按結構切、語意切塊各自的適用場景與代價
- Metadata 設計:來源、章節、位置資訊怎麼存,讓後續過濾更精準
- 表格和程式碼的特殊處理,防止切壞格式
- 實戰:同一份語料三種切法效果對比,看數字說話
觀念一:為什麼一定要切
先確認一件事:第 1 課提過「長 context 不是萬靈丹」,但這堂課要講的不是模型 context 上限,而是向量語意的問題。
把一份 50 頁的 PDF 直接送進 embedding 模型,得到的是這整份文件的語意向量——它代表這份文件「整體」在說什麼,而不是你要查的那個段落在說什麼。搜尋時你用一個具體問題去比對,卻拿到一個模糊的整體摘要,自然很難精準命中。

切塊的核心邏輯是:讓每一個向量只負責表達一個「最小語意單位」,這樣 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% 各跑一次評測,看數字決定。

觀念三:三種切法的比較
方法一:固定長度切(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 回答「根據提供的資料」,完全無法追溯。這個問題在切塊時就已經埋下了——如果 Document 的 metadata 裡沒有 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]
作業
- 找一份你實際工作中會用到的文件(公司 SOP、產品說明書、技術規格、電商商品描述都可以),跑完整的三種切法對比,記下各種切法切出的 chunk 數量和平均長度。
- 設計至少 5 個測試問題,涵蓋「問事實」、「問步驟」、「問比較」三種類型,用這 5 個問題評測三種切法哪種召回品質最好。
- 進階題:在 Metadata 裡加上
chunk_index和total_chunks,改寫 compare.py,讓它在召回 Top 1 之後,自動把相鄰的前後各一個 chunk 一起拿回來——體驗一下 Parent-Child Retrieval 的效果。
下一課預告
切塊切得好只解決了「文件能被正確 embed」的問題,但檢索本身還有很多可以優化的地方。第 5 課的主題是檢索品質:Hybrid Search(向量搜尋 + 關鍵字搜尋的混合)和 Rerank(用更強的模型對初步召回結果二次排序)。這兩個技術組合起來,可以大幅提升召回的精準度——尤其對那些包含專有名詞、縮寫、產品型號的技術文件,效果特別明顯。你的切塊工作在這堂結束,下一堂讓這些 chunk 被更準確地找到。