NLP Day 26 RAG 管線實作:文件切分、嵌入與檢索
執行需求:Colab T4 可跑。本篇在 Colab 免費 T4(16 GB VRAM)上用一個真實的維基百科條目(台北,CC BY-SA 4.0)對照 three fixed strategies:fixed chunking、recursive chunking、semantic chunking。每種策略各切出約 40–60 段、用 paraphrase-multilingual-MiniLM-L12-v2 編碼、再用相同的 5 個查詢做檢索品質比較,整個流程約 8–10 分鐘(含模型下載)。CPU 也能跑但編碼時間會拉長到 20 分鐘以上。LangChain 0.3.x 提供 RecursiveCharacterTextSplitter、CharacterTextSplitter 等內建切分器,本篇會示範如何搭配自製的 semantic chunker。
引言
Day 25 我們把 RAG 拆成檢索、增強、生成三段,並指出一個關鍵觀念:「檢索品質決定 RAG 上限」。要提升檢索品質,最容易被忽略、卻影響最大的環節是「文件切分」。同一段維基百科文字,用 200 字一段切成 100 段、與用 1000 字一段切成 20 段,檢索結果可能差到 30% 的 recall@5。原因是切分粒度直接決定了「語意的最小單元」:太粗,每段含太多概念,語意被稀釋;太細,每段只講半件事,召回時無法形成完整上下文。
本篇會示範三種主流切分策略。Fixed chunking 是最直觀的「每 N 字切一段」,實作簡單但容易把句子切在奇怪的地方;recursive chunking 是 LangChain 0.3.x 預設的策略,依序嘗試段落、句子、單字等分隔符,盡量在語意邊界切;semantic chunking 用嵌入模型的相似度決定切點,相鄰句子相似度低就視為主題轉換處,是 2024 年開始流行的進階策略。我們會用同一個維基百科條目,把三種策略的切分結果與檢索品質並排比較,讓你看到「切分」這件事對 RAG 的實質影響。
讀完這篇你會了解:fixed / recursive / semantic 三種策略的差別、怎麼用 LangChain 0.3.x 的 text splitters、semantic chunker 要怎麼自己實作、以及在不同場景下該選哪一種。明天我們會把切完的段落送進查詢改寫與混合檢索,進一步提升檢索品質。
三種切分策略的核心觀念
Fixed chunking 是最簡單的「每 N 個字元切一段」。它的好處是實作簡單(text[i:i+N])、每段長度一致;壞處是會把句子甚至單字切在奇怪的位置(例如「深度學習是機器學習的[]一支」),語意不完整會讓嵌入向量失真、檢索召回率下降。實務上固定切分通常會加 overlap)讓相鄰段塊共用 10–20% 的字元,緩解切在邊界的問題;但這只是治標,真正的問題是 fixed chunking 不懂語意邊界。
Recursive chunking 是 LangChain 0.3.x 的預設策略(RecursiveCharacterTextSplitter)。它先嘗試用段落分隔符("\n\n")切,如果切出的段太長就改用句子分隔符(". " 或 "。 "),再太長就用單字分隔符(" "),最後退回字元切。這種「由粗到細」的策略可以盡量保留語意邊界,是大多數 RAG 系統的實務預設。Recursive 對「段落結構清晰」的文件(例如維基百科、論文、技術文件)特別有效;對「段落結構模糊」的文件(例如對話紀錄、逐字稿)會退化成 fixed。
Semantic chunking 是 2024 年開始流行的進階策略。它的核心想法是「相鄰句子的語意不連續時,就視為主題轉換」。實作上先用句子切分器把文章切成句,再用嵌入模型編碼每句,計算相鄰句子的 cosine 相似度;當相似度低於某個閾值(例如 0.5),就把這個位置當作切點。這個策略的好處是「語意完整」、壞處是「計算成本高」(每篇文章要 N 次嵌入)。本篇會示範如何用 sentence-transformers 自己實作一個 semantic chunker。
實務上還有更進階的策略——LLM 切分:用 LLM 直接讀段落、判斷哪裡是主題邊界、輸出結構化的切分結果(例如 JSON list of chunks)。這個策略對長文件的「隱含主題」切得很準,但成本是 LLM 呼叫費用與延遲;對中小企業來說,本篇示範的三種策略已經能覆蓋九成情境,LLM 切分留給對精度極度敏感的場景(例如法律文件、醫療紀錄)。另一個相關概念是「proposition chunking」:用 LLM 把段落拆成命題級的事實句子(fact-level propositions),每個命題作為一個 chunk——這是 2024 年底開始被廣泛引用的進階做法,特別適合問答密集、需要精準對應到單一事件的場景。
完整實作:同一條目、三種切分、檢索品質比較
以下範例在 Colab T4 上跑約 8–10 分鐘。我們用「台北」條目(CC BY-SA 4.0)做測試語料,分別用三種策略切分,再用 5 個查詢比較檢索結果。執行前需要:pip install langchain==0.3.7 langchain-text-splitters==0.3.0 qdrant-client==1.12.0 sentence-transformers==3.4.1 wikipedia-api==0.6.0。
# 1. 安裝套件
pip install -q langchain==0.3.7 langchain-text-splitters==0.3.0 qdrant-client==1.12.0 sentence-transformers==3.4.1 wikipedia-api==0.6.0
這段安裝四個套件。langchain 0.3 是當時穩定版(2025 年 3 月),提供 RecursiveCharacterTextSplitter 與 CharacterTextSplitter。langchain-text-splitters 0.3 是 0.3 系列拆分出來的獨立套件,專門放切分器。qdrant-client 與 sentence-transformers 沿用 Day 23–25 的版本。
# 2. 抓取「台北」條目作為測試語料
import wikipediaapi
WIKI = wikipediaapi.Wikipedia(user_agent="hao-code-nlp-day26", language="zh")
page = WIKI.page("台北")
DOC_TEXT = page.text
print(f"「台北」條目:{len(DOC_TEXT)} 字")
# 輸出:「台北」條目:12,450 字(實際會略有不同)
這段從維基百科抓取「台北」條目。page.text 回傳純文字內容、已去除 HTML 標籤。內容採 CC BY-SA 4.0 授權,這裡僅作為 RAG 切分實驗的測試語料,不作為事實依據。len(DOC_TEXT) 約 12,000 字,足以展示三種切分策略的差異;如果用更大語料(例如整個條目家族),切分差異會更明顯。
# 3. Fixed chunking:每 400 字切一段,overlap 50
def fixed_chunk(text: str, chunk_size: int = 400, overlap: int = 50) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start = end - overlap
return [c for c in chunks if c.strip()]
fixed_chunks = fixed_chunk(DOC_TEXT, chunk_size=400, overlap=50)
print(f"Fixed:{len(fixed_chunks)} 段,平均 {sum(len(c) for c in fixed_chunks) / len(fixed_chunks):.0f} 字")
# 輸出(實際會略有不同):Fixed:38 段,平均 360 字
這段是 fixed chunking 的最簡實作。chunk_size=400 是常見起點:對中文而言,400 字約 1–2 段維基百科文字,足以涵蓋一個小主題又不會太長。overlap=50 是經驗值,避免切在句子中間導致下一段開頭幾個字重複以維持上下文連貫。注意 text[start:end] 在中文字元上完全安全(不會切到半個字)。這個實作沒有任何語意判斷,是純粹的固定長度切分。
# 4. Recursive chunking:用 LangChain 0.3 的 RecursiveCharacterTextSplitter
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 中文分隔符:雙換行、句號、全形句號
SEPARATORS = ["\n\n", "。", "!", "?", "\n", ";", ",", " ", ""]
splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=50,
separators=SEPARATORS,
length_function=len,
is_separator_regex=False,
)
recursive_chunks = splitter.split_text(DOC_TEXT)
print(f"Recursive:{len(recursive_chunks)} 段,平均 {sum(len(c) for c in recursive_chunks) / len(recursive_chunks):.0f} 字")
# 輸出(實際會略有不同):Recursive:42 段,平均 370 字
這段用 LangChain 0.3 的 RecursiveCharacterTextSplitter。separators 給一個中文優先順序:先嘗試雙換行(段落邊界),再嘗試句號(句子邊界),再嘗試換行、半形分號、全形分號、全形逗號、空格,最後才退回單字切分。chunk_size=400、chunk_overlap=50 與 fixed 一致,方便對照。length_function=len 是中文字元的計算方式(不需 token 化,len(s) 直接回傳字元數)。
Recursive 比 fixed 多 4 段(42 vs 38),原因是 recursive 會優先在「雙換行」與「句號」切,固定長度只是上限。如果某個段落剛好 380 字,recursive 不會把它硬切到 400 字;fixed 則會把下一段的開頭硬塞進去。結果是 recursive 的段數更接近「語意單元數量」,平均長度更穩定。
# 5. Semantic chunking:用嵌入相似度決定切點(自製)
import numpy as np
import re
from sentence_transformers import SentenceTransformer
# 先用句號把文章切成句
SENTENCE_SEP = re.compile(r"(?<=。)\s*")
sentences = [s for s in SENTENCE_SEP.split(DOC_TEXT) if len(s.strip()) > 4]
print(f"句數:{len(sentences)}")
model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
def semantic_chunk(sentences: list[str], similarity_threshold: float = 0.5) -> list[str]:
"""相鄰句子 cosine 相似度低於 threshold 就視為主題切換。"""
embs = model.encode(sentences, convert_to_numpy=True)
chunks = []
buf = [sentences[0]]
for i in range(1, len(sentences)):
sim = float(np.dot(embs[i-1], embs[i]) / (np.linalg.norm(embs[i-1]) * np.linalg.norm(embs[i])))
if sim < similarity_threshold:
chunks.append("".join(buf))
buf = [sentences[i]]
else:
buf.append(sentences[i])
if buf:
chunks.append("".join(buf))
return [c for c in chunks if len(c) > 30]
semantic_chunks = semantic_chunk(sentences, similarity_threshold=0.5)
print(f"Semantic:{len(semantic_chunks)} 段,平均 {sum(len(c) for c in semantic_chunks) / len(semantic_chunks):.0f} 字")
# 輸出(實際會略有不同):Semantic:55 段,平均 220 字
這段自製 semantic chunker。先用 regex 以句號切句((?<=。)\s* 保留句號、只在後面加空格的時候切),再用 model.encode 把每句編碼成向量。迴圈計算相鄰句子的 cosine 相似度,低於 0.5 時視為主題轉換、累積成一個 chunk。Semantic 切出 55 段、平均 220 字,比 fixed 與 recursive 都短;原因是 semantic 會在「語意斷層」切,這種位置往往在兩三句話以內就出現。
# 6. 把三種切分結果編碼並寫進三個 Qdrant collection(沿用 Day 23/24 模式)
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
client = QdrantClient(":memory:")
def build_collection(name: str, chunks: list[str]):
client.create_collection(
collection_name=name,
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)
vectors = model.encode(chunks, batch_size=32, convert_to_numpy=True).tolist()
points = [PointStruct(id=i, vector=v, payload={"text": t, "idx": i})
for i, (v, t) in enumerate(zip(vectors, chunks))]
client.upsert(collection_name=name, points=points)
print(f"{name}:{len(points)} 點")
build_collection("fixed", fixed_chunks)
build_collection("recursive", recursive_chunks)
build_collection("semantic", semantic_chunks)
# 輸出(實際會略有不同):
# fixed:38 點
# recursive:42 點
# semantic:55 點
這段把三種切分結果編碼並寫進 Qdrant。build_collection 對每種策略做一次「建 collection → 編碼 → 上傳」,三個 collection 互相獨立。這樣設計是為了「同查詢對三種策略」的比較公平——只有切分不同,其餘(嵌入模型、距離度量、向量維度)完全相同。model.encode 在 T4 上編碼 55 段約 8 秒;55 段加上前面的 38 與 42 段,總編碼時間約 20 秒。
# 7. 五個查詢 × 三種策略的檢索結果比較
QUERIES = [
"台北 101 的高度",
"捷運系統的營運時間",
"台北的歷史發展",
"台北的地理位置",
"夜市與小吃文化",
]
for q in QUERIES:
q_vec = model.encode([q], convert_to_numpy=True).tolist()[0]
print(f"\n=== 查詢:{q} ===")
for name in ["fixed", "recursive", "semantic"]:
hits = client.search(collection_name=name, query_vector=q_vec, limit=1)
top = hits[0].payload["text"][:50].replace("\n", " ")
print(f" [{name:9s}] {top}…")
# 範例輸出(會因模型與切分略有不同):
# === 查詢:台北 101 的高度 ===
# [fixed ] 台北 101 是台灣最高的建築…
# [recursive] 台北 101 是台灣最高的建築…
# [semantic ] 台北 101 是台灣最高的建築,總高度 508 公尺…
這段用 5 個查詢對三種策略各跑一次檢索、印出 top-1 段落的開頭 50 字。實務上我們會看「top-1 是否真答到問題」、「top-3 中是否有完整段落」、「無關段落的比例」三項指標。在這個小型測試中,三種策略的 top-1 看起來都答對了,但當查詢更細節(例如「台北 101 的抗震設計」),semantic 與 recursive 往往能精準命中、fixed 容易召回「台北 101 的高度」這類粗略段落。本篇的對照只能展示「切分有差」,真正的評估需要更完整的 ground truth,這會留到 Day 29 的評估章節。
常見錯誤與踩雷
錯誤一:Fixed chunking 把一段中文切成半個字或半個詞。text[i:i+400] 在中文字元串上沒事,但如果你不小心用了 text.split()[:400](以詞切)會把段落順序打亂。對應排查:中文文件切分用字元索引(text[i:i+N]),不要用 split() 或 re.findall 先切詞再切片。
錯誤二:Recursive 的 separators 沒包含中文標點。預設 RecursiveCharacterTextSplitter 的 separators 是英文標點(["\n\n", "\n", " ", ""]),對中文文章會直接退回字元切分,失去語意邊界的意義。對應排查:給中文文章要明確加上 ["\n\n", "。", "!", "?", "\n", ";", ",", " ", ""],本篇的 SEPARATORS 是經驗起點。
錯誤三:Semantic chunking 的 similarity_threshold 設太高,導致每句都獨立成一段。threshold=0.5 是經驗值;如果設成 0.8,會幾乎每句都切、失去 chunk 意義。對應排查:先用一個小語料(例如 10 段)畫出相似度分佈直方圖,找「自然斷層」的相似度值,再設 threshold。
錯誤四:Semantic chunking 把長段落切成 0 字的空段。buf = [sentences[i]] 重新開始時若 sentences[i] 太短(例如只有標點),會出現 0 字空段。對應排查:在最後加 return [c for c in chunks if len(c) > 30] 過濾掉空段。
錯誤五:把切分後的 chunks 直接丟進 LLM,沒考慮 chunk 之間的順序。如果檢索回傳的 3 段來自文章的「開頭、中間、結尾」,LLM 會以為這是三個獨立的主題、無法拼接。對應排查:在 metadata 中存 chord_order(段落在原文的順序),增強階段把同一原文的相鄰段合併後再送進 prompt。
效能與實務提醒
三種切分策略的計算成本差異大。Fixed 是 O(N)(純字串切片)、Recursive 是 O(N×層數)(每層分隔符掃一次)、Semantic 是 O(N×K)(K 是嵌入呼叫次數)。本篇「台北」條目約 12,000 字、固定切 38 段約 0.01 秒、Recursive 約 0.1 秒、Semantic 約 8 秒。Semantic 在大規模語料上會是瓶頸,10 萬篇文章可能要 30 分鐘以上;實務上會把 Semantic 與 Recursive 並用:先用 Recursive 切成粗段(例如 1000 字),再用 Semantic 在粗段內細切。
另一個實務選擇是chunk 大小。本篇用 400 字、overlap 50,是中文短問答的甜蜜點。但對企業內部長文件(一份 50 頁的產品手冊),400 字太細;改用 800–1500 字、overlap 100–200 比較合理。原則是:chunk 越長,每段含越多概念(語意被稀釋);chunk 越短,每段含越少概念(缺上下文)。最佳值要靠 Day 29 的評估來定,不是猜的。
除了 chunk 大小,還有兩個常被忽略的設計選擇。第一是 overlap 大小:本篇固定 overlap=50,相當於 chunk_size 的 12.5%。這個比例對 400–800 字的 chunk 通常合適;對 1500 字以上的長 chunk 可以調到 8–10%。Overlap 太低容易在句子邊界丟失資訊,太高則會讓相鄰 chunks 高度重複、浪費儲存與計算。
一個常被忽略的設計點是metadata 設計。除了原文,metadata 至少要存 source(文件來源)、chunk_id(段塊 ID)、chunk_order(在原文的順序)、section(所在章節,例如「歷史」「地理」)。Day 24 我們已經示範過 metadata filter;今天則提醒:metadata 在切分階段就要設計好,後續檢索與生成才用得上。實務上 metadata 還會加上 created_at(建立時間)、author(作者)、version(版本)、tags(人工或自動產生的標籤)。這些欄位讓 Day 27 的混合檢索可以做更精準的 filter,也讓 Day 29 的評估能依 metadata 分群分析。
小結
今天把 RAG 管線的第一步「文件切分」展開成完整實作:我們用「台北」維基百科條目(CC BY-SA 4.0),對照了三種主流策略——fixed(38 段、平均 360 字)、recursive(42 段、平均 370 字)、semantic(55 段、平均 220 字)。Recursive 是 LangChain 0.3.x 的預設,適合大多數場景;Semantic 在「主題切換明顯」的文件上表現最好,但計算成本高;Fixed 是最簡單但容易切壞語意邊界的策略。重點回顧:chunk_size=400 + overlap=50 是中文短問答的起點、recursive 的 separators 一定要包含中文標點、semantic threshold 0.5 是經驗值、最佳 chunk 大小要靠評估而不是猜測。我們也展示了「同一查詢對三種策略」的並排比較,驗證了「切分對檢索品質有實質影響」這個核心觀念。
需要再次強調的是:沒有「最好」的切分策略。固定長度、語意邊界、計算成本三者永遠在權衡。對中文短問答、中等規模(< 10 萬段)的 RAG 系統,recursive 是穩定的預設;對需要極高召回率(例如法律、醫療)且願意承擔計算成本的場景,semantic 與 recursive 並用是最務實的選擇。明天我們會把切完的段落加上「查詢改寫」與「BM25 + 向量混合檢索」,用 reciprocal rank fusion 把兩個檢索器的結果融合,進一步提升 top-k 的品質。
結語
今天的重點是「把文件切分從直觀猜想變成可控實驗」。我們從「為什麼切分重要」出發,介紹了 fixed / recursive / semantic 三種策略的差異,用同一條維基百科條目把三種切分各做一次、用同一組查詢各跑一次檢索,並用 top-1 段落開頭印出來做粗略對照。讀完這篇你應該能回答:fixed 與 semantic 的核心差別是什麼?LangChain 0.3.x 的 RecursiveCharacterTextSplitter 為什麼要給中文文章加 separators?semantic chunking 的 threshold 怎麼設?chunk_size 與 overlap 怎麼選?metadata 在切分階段要設計哪些欄位?
切分只是檢索優化的一環。明天,我們會把切完的段落加上「查詢改寫」與「BM25 + 向量混合檢索」,用 reciprocal rank fusion 把兩個檢索器的結果融合,進一步提升 top-k 的品質。
延伸資源
- LangChain 0.3 Text Splitters 官方文件(2025):
https://python.langchain.com/docs/how_to/recursive_text_splitter/,RecursiveCharacterTextSplitter、CharacterTextSplitter、TokenTextSplitter的完整 API。 - LangChain 0.3 semantic chunking 官方文件(2025):
https://python.langchain.com/docs/how_to/semantic-chunker/,內建的SemanticChunker(基於百分位數的切分)與本篇自製版本的差異。 - Greg Kamradt 的 semantic chunking 影片筆記(2024):
https://github.com/FullStackRetrieval-com/RetrievalTutorials,5 Levels of Text Splitting 的原始碼與視覺化。 - Qdrant 1.12 官方文件(2025):
https://qdrant.tech/documentation/,create_collection、upsert、search的完整 API。 - Greg Kamradt 五段切分法影片(2024):YouTube 搜尋「5 Levels of Text Splitting」,從最簡字元切到 LLM 切分的視覺化對照。
留言
張貼留言