LangChain – Documents 與 Text Splitter:深入了解 Document 格式
簡介
在 LangChain 的資料管線中,Document 是資訊流的最小單位。無論是 PDF、Word、Markdown,甚至是爬蟲抓下來的 HTML,最後都會被轉換成 Document 物件,才能交給 Retriever、VectorStore 或 LLM 做後續處理。
了解什麼是 Document、它的結構與常見格式,對於建立可靠的檢索式問答(RAG)系統至關重要。本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步掌握 Document 的使用方式。
核心概念
1. Document 物件的基本結構
在 LangChain(JS/TS)中,Document 只是一個簡單的 TypeScript 介面:
export interface Document {
pageContent: string; // 真正的文字內容
metadata?: Record<string, any>; // 相關的額外資訊(檔名、來源、頁碼…)
}
- pageContent 必須是純文字;若原始檔案是 PDF、Word 等二進位格式,必須先經過 loader 轉成文字。
- metadata 是可選的鍵值對,常用來保存檔案路徑、作者、建立時間或自訂的標籤,方便之後的過濾或排序。
重點:即使同一檔案有多頁,也可以把每一頁切成獨立的
Document,只要在metadata裡保留pageNumber,後續搜尋時就能精確定位。
2. 常見的 Document Loader
LangChain 提供多種內建 Loader,以下列出最常用的幾種:
| Loader | 支援格式 | 典型使用情境 |
|---|---|---|
PDFLoader |
法規、報告、論文 | |
DocxLoader |
DOCX | 會議記錄、企劃書 |
TextLoader |
.txt、.md | 原始筆記、程式碼說明 |
CSVLoader |
CSV | 表格化資料(可先轉成文字) |
WebBaseLoader |
HTML (URL) | 網站爬蟲取得的內容 |
每個 Loader 會回傳 Document[],開發者只要把回傳值丟給後續的 TextSplitter 或 VectorStore 即可。
3. Text Splitter 的角色
LLM 通常有 token 數量上限(例如 OpenAI gpt‑3.5‑turbo 約 4,096 token),直接送入整篇長文件會超過限制。Text Splitter 會把 Document 按段落、句子或固定長度切割成「較小」的 chunk,確保每個 chunk 都在 token 上限內。
LangChain 常用的 Splitter 包括:
RecursiveCharacterTextSplitter(依字元、段落遞迴切割)CharacterTextSplitter(單純固定長度切割)MarkdownHeaderTextSplitter(依 Markdown 標題層級切割)
4. 為什麼要關注 Document 的 格式?
不同格式的檔案在轉成文字時,往往會遺失版面資訊或產生雜訊。例如:
- PDF 可能會把表格內容變成連續的字串,需自行處理換行。
- DOCX 中的腳註會被當成正文,若不過濾會影響向量相似度。
- Markdown 的程式碼區塊若不保留語言標籤,LLM 可能無法正確解讀。
了解各格式的特性,才能在 Loader → Document → Splitter 的流程中加入適當的前處理與後處理。
程式碼範例
以下範例使用 Node.js(LangChainJS)展示如何從不同檔案載入、建立 Document、再切割成可供向量化的 chunk。
範例 1:載入 PDF 並保留頁碼
import { PDFLoader } from "langchain/document_loaders/fs/pdf";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
async function loadPdf(path) {
// PDFLoader 會自動把每一頁轉成 Document
const loader = new PDFLoader(path);
const docs = await loader.load(); // Document[]
// 加入自訂 metadata(來源檔案、載入時間)
const enriched = docs.map((doc, idx) => ({
pageContent: doc.pageContent,
metadata: {
source: path,
pageNumber: idx + 1,
loadedAt: new Date().toISOString(),
},
}));
// 用 RecursiveCharacterTextSplitter 切成 1000 字元的 chunk
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
});
return await splitter.splitDocuments(enriched);
}
loadPdf("./data/annual_report.pdf").then(chunks => {
console.log(`產生 ${chunks.length} 個 chunk`);
});
說明:
metadata.pageNumber讓日後查詢時能直接回傳「第 X 頁」的答案。
範例 2:載入 DOCX 並剔除腳註
import { DocxLoader } from "langchain/document_loaders/fs/docx";
import { CharacterTextSplitter } from "langchain/text_splitter";
async function loadDocx(path) {
const loader = new DocxLoader(path);
const rawDocs = await loader.load(); // 仍是 Document[]
// 移除可能的腳註或註解(簡易正則)
const cleaned = rawDocs.map(doc => ({
pageContent: doc.pageContent.replace(/\[\d+\]|\(\d+\)/g, ""), // 去掉 [1]、(2) 之類的腳註
metadata: { source: path, cleaned: true },
}));
const splitter = new CharacterTextSplitter({ chunkSize: 800, chunkOverlap: 100 });
return await splitter.splitDocuments(cleaned);
}
範例 3:從 Markdown 檔案依標題層級切割
import { TextLoader } from "langchain/document_loaders/fs/text";
import { MarkdownHeaderTextSplitter } from "langchain/text_splitter";
async function loadMarkdown(path) {
const loader = new TextLoader(path);
const [doc] = await loader.load(); // 只會回傳一個 Document
const splitter = new MarkdownHeaderTextSplitter({
headings: ["#", "##", "###"], // 只切到第 3 級標題
chunkOverlap: 0,
});
const chunks = await splitter.splitDocuments([doc]);
// 每個 chunk 的 metadata 會自動帶入對應的標題層級資訊
return chunks;
}
範例 4:結合多種 Loader 建立統一的 Document 集合
async function loadMixedSources() {
const pdfDocs = await new PDFLoader("./data/contract.pdf").load();
const docxDocs = await new DocxLoader("./data/meeting.docx").load();
const mdDocs = await new TextLoader("./data/notes.md").load();
// 統一加入 source 標記,方便後續追蹤
const allDocs = [...pdfDocs, ...docxDocs, ...mdDocs].map(doc => ({
pageContent: doc.pageContent,
metadata: {
source: doc.metadata?.source || "unknown",
},
}));
const splitter = new RecursiveCharacterTextSplitter({ chunkSize: 1200, chunkOverlap: 150 });
return await splitter.splitDocuments(allDocs);
}
範例 5:自訂 Text Splitter(依句號切割)
import { TextSplitter } from "langchain/text_splitter";
class SentenceSplitter extends TextSplitter {
async splitText(text) {
// 以中文句號、問號、驚嘆號為分界
return text.split(/(?<=[。?!])/);
}
}
async function splitBySentence(doc) {
const splitter = new SentenceSplitter({ chunkSize: 2000, chunkOverlap: 0 });
return await splitter.splitDocuments([doc]);
}
技巧:自訂 Splitter 可以配合特定領域(法律條文、程式碼)做更精細的切割。
常見陷阱與最佳實踐
| 陷阱 | 可能的影響 | 解決方法 / 最佳實踐 |
|---|---|---|
| 載入 PDF 時文字順序錯亂 | 檢索結果不相關 | 使用 pdf-parse 的 maxPages 或改用 pdf2json,必要時手動調整 metadata.pageNumber |
| 大量雜訊(表格、腳註)被納入向量 | 相似度下降,搜尋不準確 | 在 Loader 後加入 正則過濾 或 HTML/Markdown 清理,只保留正文 |
| Chunk 太小(如 100 token) | 失去上下文,LLM 回答斷斷續續 | 設定 chunkOverlap(200~300 token)以保留前後文 |
| 忘記保存來源 metadata | 無法回溯答案來源,失去可解釋性 | 必加 metadata.source、metadata.pageNumber,必要時加入 metadata.section |
| 不同檔案的編碼不一致(UTF‑8 vs Big5) | 文字亂碼,載入失敗 | 統一使用 fs.readFileSync(path, "utf8") 或在 Loader 中指定 encoding |
最佳實踐小結
- 先清理,再切割:Loader → 前處理(去除雜訊) → Document → Splitter。
- 保留足夠的 metadata:來源、頁碼、標題層級,讓後續的 RAG 能回傳「依據哪裡」的答案。
- 選擇合適的 Splitter:文件類型不同,切割策略也要調整(Markdown 用 HeaderSplitter、長報告用 RecursiveSplitter)。
- 測試 token 數:使用
model.getNumTokens(text)(或 OpenAI 的 token 計算工具)確認每個 chunk 不超過模型上限。 - 版本管理:Document 生成後若有更新,務必重新載入、重新向量化,避免「舊資料」污染搜尋結果。
實際應用場景
| 場景 | 為何需要 Document 格式化 | 典型流程 |
|---|---|---|
| 企業內部知識庫(FAQ、手冊) | 文檔類型多樣(PDF、Word、Markdown),需統一索引 | Loader → 加入 metadata.department → Split → 向量化 → Retriever |
| 法律合規檢索 | 法條、條例往往以 PDF 發布,且條文編號重要 | PDFLoader → 保留 articleNumber、section → RecursiveSplitter(保留條文完整) |
| 程式碼說明文件 | 多檔案的 README、API 手冊,需保留程式碼區塊語言標籤 | TextLoader(.md) → MarkdownHeaderSplitter → 自訂 codeLanguage metadata |
| 客服聊天機器人 | 客戶問題往往來自網頁 FAQ,需即時抓取最新內容 | WebBaseLoader → 去除 HTML 標籤 → TextSplitter(句子) → 向量化 |
| 學術研究助手 | 論文 PDF 需要保留章節與圖表說明 | PDFLoader → 自訂正則保留圖表說明 → HeaderSplitter(章節) |
案例:某金融公司使用 LangChain 建置合規檢索系統,先用
PDFLoader把所有監管報告轉成 Document,metadata 記錄reportDate、regulationSection,再以RecursiveCharacterTextSplitter(chunkSize 1500)切割。最後把向量存入 Pinecone,結合metadataFilter(如regulationSection: "第5條")即可即時回傳「第5條」的原文與所在頁碼,提升審核效率 40%。
總結
Document 是 LangChain 資料流的核心,正確的 格式化、metadata 設計 與 適切的 Text Splitter 直接決定了向量檢索與 LLM 回答的品質。
本文從概念說明、程式碼實作、常見陷阱與最佳實踐,最後列出多樣的應用場景,提供了一套 從原始檔案到可查詢向量 的完整操作藍圖。只要遵循「載入 → 清理 → 加 metadata → 切割」的四步走,無論是 PDF、DOCX、Markdown,甚至是即時爬取的網頁,都能順利轉換為高品質的 Document,為你的 RAG 系統奠定堅實基礎。祝開發順利,玩得開心!