昨天我們裝好了 Ollama、模型與 Python 套件。今天正式寫第一段 RAG 程式:把 data/ 裡的文件讀進來,再切成適合檢索的小段落(chunks)。這是 RAG 流程的第一個環節,也是最容易被低估的一步,因為切得好不好,會直接影響後面的檢索品質。
data/ 裡的文件chunk_size、chunk_overlap 的意義直接把整份文件做成一個向量有兩個問題:
所以我們把文件切成大小適中的片段,每個片段各自向量化。檢索時只撈出最相關的幾段,既精準又省空間。
在專案根目錄建立 build_rag.py,先寫載入的部分:
from langchain_community.document_loaders import DirectoryLoader, TextLoader
def load_documents(data_dir: str = "data"):
loader = DirectoryLoader(
data_dir,
glob="**/*.md", # 若你的檔案是 txt,改成 "**/*.txt"
loader_cls=TextLoader,
loader_kwargs={"encoding": "utf-8"},
)
return loader.load()
if __name__ == "__main__":
docs = load_documents()
print(f"共載入 {len(docs)} 份文件")
for doc in docs:
print(doc.metadata["source"], "→", len(doc.page_content), "字")
執行:
uv run python build_rag.py
你應該會看到每份文件的路徑與字數。如果文件數量是 0,先檢查 data/ 位置與 glob 副檔名是否對得上。
接著在同一個檔案加入切割函式:
from langchain_text_splitters import RecursiveCharacterTextSplitter
def split_documents(docs, chunk_size: int = 500, chunk_overlap: int = 50):
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
add_start_index=True,
)
return splitter.split_documents(docs)
這裡每個參數都值得了解:
| 參數 | 意義 |
|---|---|
chunk_size |
每個 chunk 的最大長度,單位是字元數(預設以 len 計算),中文一個字算一個 |
chunk_overlap |
相鄰 chunk 之間重疊的字元數,避免關鍵資訊剛好被切在邊界而遺失語意 |
separators |
切割時依序嘗試的分隔符,會優先在段落處切,不行再退到句子、再退到字詞 |
add_start_index |
在 metadata 記錄 chunk 在原文的起始位置,方便日後追查 |
RecursiveCharacterTextSplitter 的運作方式是「遞迴」:先用最優先的分隔符(段落)切,如果某一段還是超過 chunk_size,就換下一個分隔符繼續切,直到每段都夠小為止。這就是它最常被當作預設選擇的原因。
把 __main__ 區塊改成:
if __name__ == "__main__":
docs = load_documents()
chunks = split_documents(docs)
print(f"原始文件:{len(docs)} 份")
print(f"切割後:{len(chunks)} 個 chunks")
lengths = [len(c.page_content) for c in chunks]
print(f"長度範圍:{min(lengths)} ~ {max(lengths)} 字,平均 {sum(lengths) // len(lengths)} 字")
print("\n--- 第 1 個 chunk ---")
print(chunks[0].page_content)
print(chunks[0].metadata)
print("\n--- 第 2 個 chunk ---")
print(chunks[1].page_content)
重點是親眼看過 chunk 的內容。請檢查三件事:
chunk_overlap 的效果)source 與 start_index 是否正確用不同的 chunk_size 比較切出來的結果:
if __name__ == "__main__":
docs = load_documents()
for size, overlap in [(200, 20), (500, 50), (1000, 100)]:
chunks = split_documents(docs, chunk_size=size, chunk_overlap=overlap)
print(f"chunk_size={size:>4}, overlap={overlap:>3} → {len(chunks)} 個 chunks")
你會觀察到:chunk_size 越小,chunk 數量越多。這個取捨沒有標準答案:
| chunk 偏小 | chunk 偏大 | |
|---|---|---|
| 優點 | 語意集中,檢索精準 | 上下文完整,不易斷章取義 |
| 缺點 | 資訊破碎,單一 chunk 可能不足以回答問題 | 一段混入多個主題,檢索容易失焦,也佔用更多 prompt 空間 |
今天完成了文件載入與切割,明天會讓這些 chunks 變得「可搜尋」:用昨天下載的 bge-m3 模型把 chunks 轉成向量,存進 Chroma 向量資料庫,並做第一次相似度搜尋。