乐于分享
好东西不私藏

文档处理实战:Loader 加载 + Splitter 分割,知识库入库第一步

文档处理实战:Loader 加载 + Splitter 分割,知识库入库第一步

大家好,我是James。

上一篇我们把 RAG 的理论基础讲透了——LLM 三大硬伤、两阶段流程、向量搜索原理。今天正式进入 RAG 工程的第一步:文档处理

你以为做 RAG 最难的是向量检索、Prompt 工程?错。80% 的 RAG 项目效果差,根源在最前面一步——文档没加载对,或者切分得一塌糊涂。Garbage In, Garbage Out,入口数据质量直接决定后面所有环节的上限。


01 文档处理在 RAG 中的位置

先看全景图,明确我们今天要做什么:

RAG 离线流水线(知识库构建)

┌─────────┐    ┌─────────┐    ┌──────────┐    ┌──────────┐
│  原始文档 │──▶│ Loader  │──▶│ Splitter │──▶│ Embedder │
│ PDF/TXT  │    │ 加载文档 │    │ 切分文本  │    │ 向量化   │
│ CSV/JSON │    │ → Document│   │ → Chunks │    │ → Vector │
│ Web/...  │    └─────────┘    └──────────┘    └──────────┘
                                                     │
                                                     ▼
                                              ┌──────────┐
                                              │ Vector DB│
                                              │ 向量数据库 │
                                              └──────────┘

今天的重点 ──────────────▶ Loader + Splitter

Loader 和 Splitter 是离线流水线的入口,决定了知识库的数据质量。 Loader 负责把各种格式的原始文件统一成 LangChain 的 Document 对象;Splitter 负责把长文档按策略切成合适大小的 chunks。

一个类比:Loader 像工厂的原材料进货部门,把不同包装的原料拆包统一放到流水线上;Splitter 像裁切工,把大块原料按规格切成标准件——后面的加工(向量化、检索)才能高效进行。


02 Document 对象:LangChain 的数据标准格式

在写代码之前,先搞清 LangChain 的核心数据结构。

LangChain 中所有文档数据都统一为 Document 对象,包含两个字段:pageContent(文本内容)和 metadata(元数据)。

import { Document } from "@langchain/core/documents";

// Document 的结构很简单
const doc = new Document({
  pageContent"这是文档的文本内容,Loader 加载后就是这个字段",
  metadata: {
    source"report.pdf",    // 来源文件
    page3,                 // 页码
    author"James",         // 自定义元数据
    createdAt"2025-01-15"// 创建时间
  },
});

console.log(doc.pageContent); // 文本内容
console.log(doc.metadata);    // 元数据字典

这个结构贯穿整个 RAG 流程——Loader 输出 Document[],Splitter 输入 Document[] 输出 Document[],Embedder 从 pageContent 提取向量,检索时 metadata 用于过滤和溯源。

Document 对象在 RAG 中的流转

Loader 输出          Splitter 输出          存入向量数据库
┌─────────────┐     ┌─────────────┐     ┌──────────────────┐
│ pageContent: │     │ pageContent: │     │ pageContent → 向量│
│ "完整文档..." │ ──▶ │ "chunk片段"  │ ──▶ │ metadata → 过滤  │
│ metadata:    │     │ metadata:    │     │ id → 唯一标识     │
│ {source:...} │     │ {source:..., │     └──────────────────┘
└─────────────┘     │  chunk: 0}  │
                    └─────────────┘

✅ 好的做法:从一开始就维护好 metadata,后续检索时可以按来源、页码过滤
❌ 坏的做法:只关心 pageContent,忽略 metadata——检索到内容后无法追溯来源


03 Document Loader:加载各种格式的文档

LangChain.js 提供了大量 Loader,覆盖主流文件格式。核心思路是:不同格式的文件 → 统一的 Document[] 输出。

3.1 加载纯文本文件

最简单的场景,TextLoader 直接读文件:

import { TextLoader } from "langchain/document_loaders/fs/text";

const loader = new TextLoader("./data/readme.txt");
const docs = await loader.load();

console.log(docs.length);              // 1(整个文件作为一个 Document)
console.log(docs[0].pageContent);       // 文件全部内容
console.log(docs[0].metadata.source);   // "./data/readme.txt"

3.2 加载 PDF 文件

PDF 是知识库最常见的格式,用 PDFLoader

import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf";

// 默认按页分割,每页一个 Document
const loader = new PDFLoader("./data/ai-report.pdf");
const docs = await loader.load();

console.log(docs.length);              // 文档页数
console.log(docs[0].metadata);
// { source: "./data/ai-report.pdf", pdf: { ... }, loc: { pageNumber: 1 } }

// 也可以合并为一个 Document
const loaderMerged = new PDFLoader("./data/ai-report.pdf", {
  splitPagesfalse// 不按页拆分,整个 PDF 合为一个 Document
});
const mergedDocs = await loaderMerged.load();
console.log(mergedDocs.length); // 1

✅ 按页分割(splitPages: true,默认):保留页码信息,方便溯源定位
❌ 整篇合并(splitPages: false):丢失页码信息,后续检索到内容不知道在哪一页

3.3 加载 CSV 文件

CSV 每行是一条记录,用 CSVLoader

import { CSVLoader } from "@langchain/community/document_loaders/fs/csv";

// 每行数据 → 一个 Document
const loader = new CSVLoader("./data/products.csv");
const docs = await loader.load();

console.log(docs[0].pageContent);
// "name: iPhone 15\nprice: 7999\ncategory: 手机"
console.log(docs[0].metadata);
// { source: "./data/products.csv", line: 1 }

// 指定某列作为 pageContent
const loaderWithColumn = new CSVLoader("./data/products.csv", {
  column"description"// 只用 description 列作为文本内容
});

3.4 加载 JSON 文件

JSON 结构灵活,JSONLoader 支持用 JSON Pointer 提取特定字段:

import { JSONLoader } from "langchain/document_loaders/fs/json";

// 默认:提取所有字符串值
const loader = new JSONLoader("./data/faq.json");
const docs = await loader.load();

// 指定 JSON Pointer,只提取特定路径的内容
// 假设 JSON 结构: [{ "question": "...", "answer": "..." }, ...]
const loaderWithPointer = new JSONLoader(
  "./data/faq.json",
  "/answer" // 只提取每个对象的 answer 字段
);
const answerDocs = await loaderWithPointer.load();
console.log(answerDocs[0].pageContent); // 第一个 FAQ 的答案内容

3.5 加载 Web 页面

爬取网页内容,CheerioWebBaseLoader 最轻量:

import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";

const loader = new CheerioWebBaseLoader(
  "https://docs.langchain.com/docs/get_started/introduction"
);
const docs = await loader.load();

console.log(docs[0].pageContent.substring(0200)); // 网页文本内容
console.log(docs[0].metadata.source); // URL

3.6 批量加载目录

真实场景不可能只加载一个文件,DirectoryLoader 批量加载整个目录:

import { DirectoryLoader } from "langchain/document_loaders/fs/directory";
import { TextLoader } from "langchain/document_loaders/fs/text";
import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf";
import { CSVLoader } from "@langchain/community/document_loaders/fs/csv";

const loader = new DirectoryLoader("./data/knowledge-base", {
  ".txt"(path) => new TextLoader(path),
  ".pdf"(path) => new PDFLoader(path),
  ".csv"(path) => new CSVLoader(path),
  // 按扩展名映射不同的 Loader
});

const allDocs = await loader.load();
console.log(`总共加载了 ${allDocs.length} 个 Document`);

// 按来源分组统计
const sourceCount = allDocs.reduce((acc, doc) => {
  const ext = doc.metadata.source.split(".").pop();
  acc[ext] = (acc[ext] || 0) + 1;
  return acc;
}, {} as Record<stringnumber>);
console.log(sourceCount); // { txt: 12, pdf: 45, csv: 8 }

✅ 用 DirectoryLoader 按扩展名映射 Loader,一次加载整个知识库目录
❌ 手动遍历文件一个个加载,代码冗余且容易漏掉格式


04 Text Splitter:为什么要切分文档

Loader 加载完成后,文档往往很长——一个 PDF 可能几万字,一个技术文档几十页。直接把整个文档向量化有三个致命问题:

为什么必须切分文档

问题一:向量质量差
┌──────────────────────────────────────────────┐
│ 一篇 10000 字的文档 → 一个向量                │
│ 这个向量是整篇文档的"平均语义"                 │
│ 用户搜一个具体问题,匹配度很低                 │
└──────────────────────────────────────────────┘

问题二:Context 浪费
┌──────────────────────────────────────────────┐
│ 检索到整篇文档,塞进 Prompt                    │
│ 10000 字里只有 200 字和问题相关                │
│ 剩下 9800 字浪费 token,稀释有效信息           │
└──────────────────────────────────────────────┘

问题三:超过模型限制
┌──────────────────────────────────────────────┐
│ 模型 context window 有上限(如 128K tokens)   │
│ 单个文档就可能超限                             │
│ 多个检索结果更放不下                           │
└──────────────────────────────────────────────┘

所以切分的目标是:把长文档按语义切成适当大小的 chunk,每个 chunk 语义相对独立完整,检索时能精确匹配用户问题。


05 RecursiveCharacterTextSplitter:最常用的分割器

RecursiveCharacterTextSplitter 是 LangChain 推荐的默认分割器,90% 的场景用它就够了。

核心原理:按一组分隔符递归地尝试分割文本。先尝试用 \n\n(段落),切不动或切完还太长就用 \n(换行),再不行用 (空格),最后才按单个字符硬切。

RecursiveCharacterTextSplitter 分割逻辑

输入文本
    │
    ▼
尝试按 "\n\n"(段落)分割
    │
    ├── chunk ≤ chunkSize?✅ 保留
    │
    └── chunk > chunkSize?继续 ↓
        │
        ▼
    尝试按 "\n"(换行)分割
        │
        ├── chunk ≤ chunkSize?✅ 保留
        │
        └── chunk > chunkSize?继续 ↓
            │
            ▼
        尝试按 " "(空格)分割
            │
            ├── chunk ≤ chunkSize?✅ 保留
            │
            └── chunk > chunkSize?继续 ↓
                │
                ▼
            按单个字符硬切

代码示例:

import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize500,     // 每个 chunk 最大 500 个字符
  chunkOverlap50,   // 相邻 chunk 重叠 50 个字符
  separators: ["\n\n""\n"" """], // 默认分隔符列表
});

const text = `第一章 产品概述

本产品是一款基于大语言模型的智能客服系统,支持多轮对话、知识库检索、工单自动创建等功能。

第二章 核心功能

2.1 多轮对话
系统支持上下文感知的多轮对话,能够记住用户在当前会话中提出的历史问题,自动关联上下文进行回答。

2.2 知识库检索
基于 RAG 架构,系统会自动从企业知识库中检索相关文档片段,结合大模型生成准确回答。支持 PDF、Word、网页等多种文档格式。`
;

const chunks = await splitter.createDocuments([text]);

chunks.forEach((chunk, i) => {
  console.log(`\n--- Chunk ${i} (${chunk.pageContent.length} chars) ---`);
  console.log(chunk.pageContent);
});

两个关键参数:

  • chunkSize:每个 chunk 的最大字符数。不是严格等于这个数,而是”不超过”
  • chunkOverlap:相邻 chunk 之间的重叠字符数。保证语义不被硬切断
chunkOverlap 的作用

chunkOverlap = 0(无重叠)
┌──────────┐ ┌──────────┐ ┌──────────┐
│  Chunk 0  │ │  Chunk 1  │ │  Chunk 2  │
└──────────┘ └──────────┘ └──────────┘
              ↑ 这里可能刚好切断一句话

chunkOverlap = 50(有重叠)
┌──────────────┐
│    Chunk 0    │
└────────┬─────┘
         │ 重叠 │
    ┌────┴─────────┐
    │    Chunk 1    │
    └────────┬─────┘
             │ 重叠 │
        ┌────┴─────────┐
        │    Chunk 2    │
        └──────────────┘
重叠区域保证上下文连续性

✅ 使用 RecursiveCharacterTextSplitter:按语义层级递归分割,尽量保留完整段落和句子
❌ 简单按固定字符数硬切:可能切在词语中间、句子中间,导致语义破碎


06 其他分割器:CharacterTextSplitter 与 TokenTextSplitter

CharacterTextSplitter

只按单一分隔符切割,不递归。 适合格式非常规律的文本(如日志、CSV 转文本):

import { CharacterTextSplitter } from "@langchain/textsplitters";

const splitter = new CharacterTextSplitter({
  separator"\n\n",   // 只按段落分割,不递归
  chunkSize500,
  chunkOverlap0,
});

const text = "段落一内容...\n\n段落二内容...\n\n段落三内容...";
const chunks = await splitter.createDocuments([text]);
// 如果某段落超过 500 字符,不会继续往下拆,直接保留(这是它和 Recursive 的区别)

TokenTextSplitter

按 token 数而非字符数切割。 当你需要精确控制 token 消耗时使用:

import { TokenTextSplitter } from "@langchain/textsplitters";

const splitter = new TokenTextSplitter({
  chunkSize200,      // 每个 chunk 最大 200 tokens
  chunkOverlap20,    // 重叠 20 tokens
  encodingName"cl100k_base"// OpenAI 的 tokenizer
});

const docs = await splitter.createDocuments([longText]);
// 每个 chunk 严格不超过 200 tokens
// 适合需要精确控制 LLM 输入 token 数的场景

三种 Splitter 对比:

┌────────────────────┬───────────────┬────────────────┬─────────────┐
│      Splitter       │   分割策略     │    适用场景     │   推荐度    │
├────────────────────┼───────────────┼────────────────┼─────────────┤
│ Recursive           │ 多级分隔符递归 │  通用文档      │  ⭐⭐⭐⭐⭐ │
│ Character           │ 单一分隔符     │ 规律文本/日志  │  ⭐⭐⭐     │
│ Token               │ Token 计数    │ 精确控制token  │  ⭐⭐⭐⭐   │
└────────────────────┴───────────────┴────────────────┴─────────────┘

✅ 默认选 RecursiveCharacterTextSplitter,除非有明确理由用其他
❌ 无脑用 CharacterTextSplitter——遇到没有规律分隔符的文本,切出来的 chunk 质量很差


07 分割参数调优:chunkSize 和 chunkOverlap 怎么选

参数选择没有银弹,取决于文档类型、Embedding 模型、检索需求。 但有一些经验法则:

chunkSize 选择策略

chunkSize 太小 vs 太大

太小(< 200 字符)
┌────────────────────────────────────────────────┐
│ "系统支持多轮对话"                               │
│                                                │
│ 问题:语义不完整,缺乏上下文                      │
│ 检索到也没法用,LLM 无法基于这一句话回答           │
└────────────────────────────────────────────────┘

太大(> 2000 字符)
┌────────────────────────────────────────────────┐
│ "第一章 产品概述... 第二章 功能... 第三章 架构..." │
│                                                │
│ 问题:包含多个主题,向量是"平均语义"              │
│ 检索精度低,塞进 Prompt 浪费 token               │
└────────────────────────────────────────────────┘

合适的大小(500-1000 字符)
┌────────────────────────────────────────────────┐
│ "2.2 知识库检索                                 │
│  基于 RAG 架构,系统会自动从企业知识库中           │
│  检索相关文档片段,结合大模型生成准确回答。         │
│  支持 PDF、Word、网页等多种文档格式。"             │
│                                                │
│ 语义完整,主题集中,长度适中 ✅                    │
└────────────────────────────────────────────────┘
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

// 不同场景的参数配置
// 场景一:技术文档 / 产品手册
const techDocSplitter = new RecursiveCharacterTextSplitter({
  chunkSize800,      // 技术文档段落较长,给大一些
  chunkOverlap100,   // 重叠保留上下文
});

// 场景二:FAQ / QA 对
const faqSplitter = new RecursiveCharacterTextSplitter({
  chunkSize300,      // FAQ 本身就短,切小一些
  chunkOverlap30,
});

// 场景三:法律合同 / 长篇报告
const legalSplitter = new RecursiveCharacterTextSplitter({
  chunkSize1000,     // 法律文本需要更多上下文
  chunkOverlap200,   // 大重叠防止条款被切断
});

chunkOverlap 经验值

一般设为 chunkSize 的 10%-20%。 太小会丢失上下文连续性,太大会导致大量重复内容浪费存储和计算。

chunkSize 与 chunkOverlap 推荐配置

┌─────────────┬────────────┬───────────────┬──────────────────┐
│  文档类型     │ chunkSize  │ chunkOverlap  │  说明            │
├─────────────┼────────────┼───────────────┼──────────────────┤
│ FAQ / 短文本  │ 200-400   │ 20-50         │ 内容短,小chunk   │
│ 技术文档      │ 500-1000  │ 50-100        │ 平衡精度和完整度  │
│ 法律/学术     │ 800-1500  │ 100-200       │ 需要完整上下文    │
│ 代码         │ 500-800   │ 50-100        │ 按函数/类切分更好 │
└─────────────┴────────────┴───────────────┴──────────────────┘


08 元数据保留:分割后别丢了来源信息

Splitter 分割后,每个 chunk 会自动继承原 Document 的 metadata,并追加 chunk 定位信息。 这一点非常关键——没有 metadata,检索到内容后无法告诉用户”这个答案来自哪个文件的第几页”。

import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

// 1. 加载 PDF
const loader = new PDFLoader("./data/product-manual.pdf");
const docs = await loader.load();

// 2. 分割
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize500,
  chunkOverlap50,
});
const chunks = await splitter.splitDocuments(docs);

// 3. 查看 metadata 继承情况
console.log(chunks[0].metadata);
// {
//   source: "./data/product-manual.pdf",
//   pdf: { ... },
//   loc: { pageNumber: 1, lines: { from: 0, to: 15 } }
// }

// metadata 自动从原 Document 继承过来了!
// 后续检索时可以用 metadata 做过滤和溯源

你还可以在加载后手动添加自定义 metadata:

import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const loader = new PDFLoader("./data/product-manual.pdf");
const docs = await loader.load();

// 手动给每个 Document 添加自定义 metadata
const enrichedDocs = docs.map((doc) => ({
  ...doc,
  metadata: {
    ...doc.metadata,
    department"产品部",          // 所属部门
    docType"产品手册",           // 文档类型
    version"v2.1",              // 版本号
    indexedAtnew Date().toISOString(), // 入库时间
  },
}));

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize500,
  chunkOverlap50,
});

// splitDocuments 会保留所有 metadata
const chunks = await splitter.splitDocuments(enrichedDocs);
console.log(chunks[0].metadata);
// { source: "...", department: "产品部", docType: "产品手册", version: "v2.1", ... }

✅ 在 Loader 之后、Splitter 之前添加自定义 metadata,分割后自动继承
❌ 在 Splitter 之后再给 chunks 加 metadata——容易漏加,且无法利用原始 Document 的信息


09 实战:从 PDF 加载到 Chunks 的完整流程

把前面所有知识串起来,写一个完整的文档处理流水线:

import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf";
import { TextLoader } from "langchain/document_loaders/fs/text";
import { DirectoryLoader } from "langchain/document_loaders/fs/directory";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { Document } from "@langchain/core/documents";

// ===== Step 1: 加载文档 =====
const loader = new DirectoryLoader("./knowledge-base", {
  ".pdf"(path) => new PDFLoader(path),
  ".txt"(path) => new TextLoader(path),
});
const rawDocs = await loader.load();
console.log(`✅ 加载完成:${rawDocs.length} 个 Document`);

// ===== Step 2: 清洗 & 丰富 metadata =====
const cleanedDocs = rawDocs
  .filter((doc) => doc.pageContent.trim().length > 50// 过滤空白/过短文档
  .map((doc) => new Document({
    pageContent: doc.pageContent
      .replace(/\s+/g" ")       // 合并多余空白
      .replace(/\n{3,}/g"\n\n"// 合并多余空行
      .trim(),
    metadata: {
      ...doc.metadata,
      processedAtnew Date().toISOString(),
      charCount: doc.pageContent.length,
    },
  }));
console.log(`✅ 清洗完成:${cleanedDocs.length} 个有效 Document`);

// ===== Step 3: 分割 =====
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize800,
  chunkOverlap100,
});
const chunks = await splitter.splitDocuments(cleanedDocs);
console.log(`✅ 分割完成:${chunks.length} 个 Chunks`);

// ===== Step 4: 统计 & 质检 =====
const stats = {
  totalChunks: chunks.length,
  avgChunkSizeMath.round(
    chunks.reduce((sum, c) => sum + c.pageContent.length0) / chunks.length
  ),
  minChunkSizeMath.min(...chunks.map((c) => c.pageContent.length)),
  maxChunkSizeMath.max(...chunks.map((c) => c.pageContent.length)),
  sources: [...new Set(chunks.map((c) => c.metadata.source))],
};
console.log("📊 分割统计:", stats);
// {
//   totalChunks: 156,
//   avgChunkSize: 623,
//   minChunkSize: 89,
//   maxChunkSize: 800,
//   sources: ["./knowledge-base/manual.pdf", "./knowledge-base/faq.txt", ...]
// }
完整流程图

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│  knowledge-  │     │  rawDocs     │     │ cleanedDocs  │
│  base/       │────▶│  (加载原始)   │────▶│ (清洗过滤)    │
│  ├── *.pdf   │     │  48 docs     │     │  42 docs     │
│  └── *.txt   │     └──────────────┘     └──────┬───────┘
└──────────────┘                                  │
                                                  ▼
                     ┌──────────────┐     ┌──────────────┐
                     │   chunks     │◀────│  Splitter    │
                     │   156 个     │     │  chunkSize:  │
                     │   avg 623字  │     │  800         │
                     └──────┬───────┘     └──────────────┘
                            │
                            ▼
                   下一步:Embedding + 存入向量数据库


10 中文文档的特殊处理

中文分割是最容易踩坑的地方。 英文有天然的空格和标点分隔,中文没有——默认的 RecursiveCharacterTextSplitter["\n\n", "\n", " ", ""] 分隔符,对中文不够友好。

import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

// 针对中文优化的分隔符
const chineseSplitter = new RecursiveCharacterTextSplitter({
  chunkSize500,
  chunkOverlap50,
  separators: [
    "\n\n",    // 段落
    "\n",      // 换行
    "。",      // 中文句号
    "!",      // 中文感叹号
    "?",      // 中文问号
    ";",      // 中文分号
    ",",      // 中文逗号(最后才用)
    " ",       // 空格
    "",        // 兜底:按字符切
  ],
});

const chineseText = `人工智能正在深刻改变各行各业。在医疗领域,AI辅助诊断系统已经能够识别X光片中的异常,准确率超过资深医生。在金融领域,智能风控系统每天处理数百万笔交易,实时识别欺诈行为。

然而,AI技术的发展也带来了新的挑战。数据隐私保护、算法偏见、就业替代等问题,都需要社会各界共同面对和解决。`
;

const chunks = await chineseSplitter.createDocuments([chineseText]);
chunks.forEach((chunk, i) => {
  console.log(`\n--- Chunk ${i} ---`);
  console.log(chunk.pageContent);
});

✅ 为中文文档定制分隔符列表,优先在句号、问号等自然断句处切割
❌ 使用默认英文分隔符处理中文——可能在词语中间硬切,导致语义断裂


11 常见坑:踩过才知道

坑一:PDF 加载出来全是乱码或空白

不同 PDF 生成工具(扫描版、加密、图片型)的解析结果差异巨大。扫描版 PDF 需要先 OCR 再加载。PDFLoader 底层用的是 pdf-parse,只能处理文字型 PDF。遇到扫描版,要先用 OCR 工具转成文字,或者换用支持 OCR 的 Loader。

坑二:chunkSize 设成 token 数但实际按字符切

RecursiveCharacterTextSplitterchunkSize 单位是字符,不是 token。中文一个字 = 一个字符 ≈ 1-2 个 token。如果你需要精确控制 token 数,用 TokenTextSplitter,或者给 RecursiveCharacterTextSplitter 配置 lengthFunction

import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { encoding_for_model } from "tiktoken";

const enc = encoding_for_model("gpt-4o");

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize500// 这里就是 500 tokens 了
  chunkOverlap50,
  lengthFunction(textstring) => enc.encode(text).length// 用 token 计数
});

坑三:Overlap 设为 0,检索时总是缺上下文

chunkOverlap = 0 意味着相邻 chunk 之间完全不重叠。如果一句话刚好被切成两半,分别在两个 chunk 里,任何一个 chunk 的语义都不完整,检索时两边都匹配不上。永远给一个合理的 overlap,至少 chunkSize 的 10%。

坑四:metadata 在自定义处理流程中被丢弃

很多人会在 Loader 之后做一些文本清洗(去特殊字符、格式化),操作时直接用 new Document({ pageContent: cleanedText }),忘了把原始的 metadata 带过去。一旦丢失 metadata,后续检索完全无法溯源。

坑五:目录下混入大文件,内存爆炸

DirectoryLoader 会递归加载目录下所有匹配文件。如果目录里混入了一个 500MB 的 PDF,Node.js 进程直接 OOM。加载前先检查文件大小,设置上限过滤。

import * as fs from "fs";
import * as path from "path";

// 过滤超大文件
const MAX_FILE_SIZE = 50 * 1024 * 1024// 50MB

const files = fs.readdirSync("./knowledge-base");
const safeFiles = files.filter((f) => {
  const filePath = path.join("./knowledge-base", f);
  const stats = fs.statSync(filePath);
  if (stats.size > MAX_FILE_SIZE) {
    console.warn(`⚠️ 跳过大文件:${f} (${(stats.size / 1024 / 1024).toFixed(1)}MB)`);
    return false;
  }
  return true;
});

总结

这篇我们完成了 RAG 流水线的第一步——文档加载与分割,核心要点:

  • Document 是 LangChain 的数据标准格式pageContent 存文本,metadata 存元信息,贯穿 RAG 全流程
  • Loader 统一了数据入口:PDF、TXT、CSV、JSON、Web 页面,不同格式统一输出 Document[]DirectoryLoader 批量加载整个目录
  • 必须切分才能高效检索:长文档直接向量化语义模糊、浪费 token、可能超限——切成 500-1000 字符的 chunk 是基本操作
  • RecursiveCharacterTextSplitter 是首选:多级分隔符递归分割,语义保留最好,90% 场景的默认选择
  • 参数调优靠场景:chunkSize 500-1000 起步,chunkOverlap 设 10%-20%,不同文档类型配置不同参数
  • 中文要定制分隔符:加入句号、问号等中文标点,避免在词语中间硬切

下一篇我们深入 LangChain Splitter 全解析——Markdown Splitter、Code Splitter 等高级分割策略,以及如何根据文档结构做语义分块,让你的 RAG 检索精度再上一个台阶。


关注我,James 的成长日记,持续分享干货,帮你在 AI 时代少走弯路。