本文 AI 產出,尚未審核

LangChain – Documents 與 Text Splitter:深入了解 Document 格式

簡介

LangChain 的資料管線中,Document 是資訊流的最小單位。無論是 PDF、Word、Markdown,甚至是爬蟲抓下來的 HTML,最後都會被轉換成 Document 物件,才能交給 RetrieverVectorStoreLLM 做後續處理。
了解什麼是 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 PDF 法規、報告、論文
DocxLoader DOCX 會議記錄、企劃書
TextLoader .txt、.md 原始筆記、程式碼說明
CSVLoader CSV 表格化資料(可先轉成文字)
WebBaseLoader HTML (URL) 網站爬蟲取得的內容

每個 Loader 會回傳 Document[],開發者只要把回傳值丟給後續的 TextSplitterVectorStore 即可。

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-parsemaxPages 或改用 pdf2json,必要時手動調整 metadata.pageNumber
大量雜訊(表格、腳註)被納入向量 相似度下降,搜尋不準確 在 Loader 後加入 正則過濾HTML/Markdown 清理,只保留正文
Chunk 太小(如 100 token) 失去上下文,LLM 回答斷斷續續 設定 chunkOverlap(200~300 token)以保留前後文
忘記保存來源 metadata 無法回溯答案來源,失去可解釋性 必加 metadata.sourcemetadata.pageNumber,必要時加入 metadata.section
不同檔案的編碼不一致(UTF‑8 vs Big5) 文字亂碼,載入失敗 統一使用 fs.readFileSync(path, "utf8") 或在 Loader 中指定 encoding

最佳實踐小結

  1. 先清理,再切割:Loader → 前處理(去除雜訊) → Document → Splitter。
  2. 保留足夠的 metadata:來源、頁碼、標題層級,讓後續的 RAG 能回傳「依據哪裡」的答案。
  3. 選擇合適的 Splitter:文件類型不同,切割策略也要調整(Markdown 用 HeaderSplitter、長報告用 RecursiveSplitter)。
  4. 測試 token 數:使用 model.getNumTokens(text)(或 OpenAI 的 token 計算工具)確認每個 chunk 不超過模型上限。
  5. 版本管理:Document 生成後若有更新,務必重新載入、重新向量化,避免「舊資料」污染搜尋結果。

實際應用場景

場景 為何需要 Document 格式化 典型流程
企業內部知識庫(FAQ、手冊) 文檔類型多樣(PDF、Word、Markdown),需統一索引 Loader → 加入 metadata.department → Split → 向量化 → Retriever
法律合規檢索 法條、條例往往以 PDF 發布,且條文編號重要 PDFLoader → 保留 articleNumbersection → RecursiveSplitter(保留條文完整)
程式碼說明文件 多檔案的 README、API 手冊,需保留程式碼區塊語言標籤 TextLoader(.md) → MarkdownHeaderSplitter → 自訂 codeLanguage metadata
客服聊天機器人 客戶問題往往來自網頁 FAQ,需即時抓取最新內容 WebBaseLoader → 去除 HTML 標籤 → TextSplitter(句子) → 向量化
學術研究助手 論文 PDF 需要保留章節與圖表說明 PDFLoader → 自訂正則保留圖表說明 → HeaderSplitter(章節)

案例:某金融公司使用 LangChain 建置合規檢索系統,先用 PDFLoader 把所有監管報告轉成 Document,metadata 記錄 reportDateregulationSection,再以 RecursiveCharacterTextSplitter(chunkSize 1500)切割。最後把向量存入 Pinecone,結合 metadataFilter(如 regulationSection: "第5條")即可即時回傳「第5條」的原文與所在頁碼,提升審核效率 40%。

總結

Document 是 LangChain 資料流的核心,正確的 格式化metadata 設計適切的 Text Splitter 直接決定了向量檢索與 LLM 回答的品質。
本文從概念說明、程式碼實作、常見陷阱與最佳實踐,最後列出多樣的應用場景,提供了一套 從原始檔案到可查詢向量 的完整操作藍圖。只要遵循「載入 → 清理 → 加 metadata → 切割」的四步走,無論是 PDF、DOCX、Markdown,甚至是即時爬取的網頁,都能順利轉換為高品質的 Document,為你的 RAG 系統奠定堅實基礎。祝開發順利,玩得開心!