深入浅出RAG——第4章:文档加载

1 阅读11分钟

📖 本章学习目标

  • 理解 Document 对象(pageContent + metadata)在 RAG 管道中的统一抽象角色
  • 使用 LangChain 的文档加载器处理 PDF、Markdown、CSV 等常见格式
  • 实现自定义加载器,从 REST API 或数据库中提取文档
  • 识别不同文件格式的解析风险点,做好异常处理

在前面两章《第2章:文本嵌入》《第3章:向量数据库入门》,我们先后搭建了Embedding 模型和向量数据库两项RAG后端能力。前者是将文本转为向量,后者则是将转换后的向量存储并提供查询能力。本文将讲解在文本嵌入之前的文档加载相关的内容,其解决的则是知识库的来源和入口问题。

一、Document 对象:RAG 管道的统一数据抽象

1. 为什么需要一个统一抽象

在实际的场景中,你的文档可能散落在各个地方,比如本地硬盘、公司的 Confluence 页面、GitHub 上的 Markdown、甚至是一封封 Outlook 邮件。文档的格式也是五花八门的,有PDF,有图片,有Markdown等等。因此,如何处理各种各类的知识文档,也是RAG里要解决的一大工程问题,即怎么把它们变成 RAG 系统能统一消费的文档?

一种常见的工程思维是无论原始数据是 PDF、Markdown 还是数据库中的一行记录,在进入 RAG 管道后都应该被标准化为同一种结构。这有点类似我们编程写一个函数时,可以接受不同类型的参数,在函数真正运行逻辑之前先对参数做归一化和规范化。许多框架和库都会才采用这种思想。

在RAG工程中,将不同类型的文档转为统一的文档结构的工具我们称为文档加载器(Document Loader) ,其基本的逻辑是对不同格式的文件采用一定的加载策略进行解析并输出一个固定的数据结构的对象共RAG管道处理。

以LangChain为例,其定义了 Document 接口作为这个统一抽象:

interface Document {
  pageContent: string;           // 文档的文本内容
  metadata: Record<string, any>; // 元数据:来源、作者、日期等
}

pageContent 是后续做 Embedding 和检索的输入,metadata 用于过滤和溯源。两者缺一不可。

有了 Document 这个统一抽象后,无论原始格式是什么,加载器都会将其转换为统一的 { pageContent, metadata } 结构。后续的管道(切分 → 向量化 → 存储)只需要处理这一种数据结构,大大简化了系统设计。

graph LR
    A[PDF] --> L1[PDFLoader]
    B[Markdown] --> L2[TextLoader]
    C[CSV] --> L3[CSVLoader]
    D[REST API] --> L4[APILoader]
    E[Database] --> L5[DBLoader]
    
    L1 --> F[Document]
    L2 --> F
    L3 --> F
    L4 --> F
    L5 --> F
    
    F --> G[统一的切分管道]
    G --> H[统一的向量化管道]

2. Document 对象示例和设计原则

// 一份从 Markdown 文件加载的 Document
const doc = {
  pageContent: "## 部署流程\n\n1. 代码审查\n2. 预发验证\n3. 生产发布",
  metadata: {
    source: "docs/deploy.md",
    title: "部署流程",
    fileType: "markdown",
    lastModified: "2026-05-20",
  },
};

metadata 不是随便设计的,它应该在加载阶段就规划好,因为后续的检索、过滤、溯源都依赖这些字段。常见的 metadata 字段包括:

  • source:文档来源(文件路径、URL、API 端点等)
  • title:文档标题
  • author:作者
  • date:创建或修改日期
  • category:分类标签
  • fileType:原始文件格式
  • version:版本号(如果文档有版本管理)

《第9章:高级检索策略》中,我们会详细讨论如何利用 metadata 进行过滤和增强检索。

3. LangChain 的 Document 类

Document的定义、设计和加载器的实现不是一成不变的,你可以自己编写,也可以使用成熟的库,比如LangChain的Document、LlamaIndex的Docuement+Reader体系,Haystack的Pipeline等。但在实际使用中,我们通常都会使用成熟的库,比如使用 LangChain 提供的 Document 类:

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

const doc = new Document({
  pageContent: "这是文档内容",
  metadata: {
    source: "example.md",
    category: "tutorial",
  },
});

console.log(doc.pageContent); // "这是文档内容"
console.log(doc.metadata.source); // "example.md"

Document 类提供了一些非常有用的方法,比如:

// 合并多个 Document
const merged = doc1.concat(doc2);

// 检查是否为空
if (doc.pageContent.length === 0) {
  console.warn("空文档,跳过");
}

本章将基于LangChain讲解主流文件格式的加载策略,并带你编写自定义加载器应对特殊数据源。更多LangChain相关的教程,你可以阅读专栏《深入浅出LangChain》

二、主流文件格式的加载策略

企业知识库中的文档格式千差万别,每种格式都有其特殊的解析挑战。下面是常见格式的加载策略对比以及LangChain提供的相应加载器。

graph TD
    A[文档格式] --> B[结构化程度高]
    A --> C[半结构化]
    A --> D[非结构化]
    
    B --> B1[CSV/Excel]
    B --> B2[JSON/XML]
    B --> B3[Database]
    
    C --> C1[Markdown]
    C --> C2[HTML]
    C --> C3[Word]
    
    D --> D1[PDF]
    D --> D2[PPTX]
    D --> D3[图片/扫描件]
    
    B1 --> E[CSVLoader]
    C1 --> F[TextLoader]
    D1 --> G[PDFLoader<br/>或专用解析服务]

1. 加载 Markdown 和纯文本

Markdown 和纯文本是最容易处理的格式,因为它们本身就是纯文本,无需复杂的解析。

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

// 加载单个文件
const loader = new TextLoader("./docs/deploy-guide.md");
const docs = await loader.load();

console.log(`加载了 ${docs.length} 个文档`);
console.log(docs[0].pageContent.slice(0, 200)); // 预览前 200 字符
console.log(docs[0].metadata.source); // "./docs/deploy-guide.md"

批量加载多个文件

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

async function loadMultipleFiles(dirPath: string): Promise<Document[]> {
  const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.md'));
  
  const allDocs: Document[] = [];
  
  for (const file of files) {
    const filePath = path.join(dirPath, file);
    const loader = new TextLoader(filePath);
    const docs = await loader.load();
    allDocs.push(...docs);
  }
  
  return allDocs;
}

const docs = await loadMultipleFiles("./docs");
console.log(`总共加载了 ${docs.length} 个文档`);

注意事项

  • TextLoader 默认使用 UTF-8 编码,如果遇到中文乱码,需要检查文件编码(见 FAQ)
  • 对于超大文件(> 10MB),建议先评估内存占用,必要时改用流式加载
  • Markdown 中的图片链接、代码块会被原样保留为文本,这通常是期望的行为

2. 加载 PDF

PDF 是最具挑战的格式。它的布局信息(双栏、表格、图片)会严重干扰文本提取。

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

const loader = new PDFLoader("./docs/annual-report.pdf", {
  parsedItemSeparator: "\n",  // 段落分隔符
  splitPages: true,            // 是否每页作为一个独立文档
});

const docs = await loader.load();

console.log(`PDF 共 ${docs.length} 页`);
for (const [i, doc] of docs.entries()) {
  console.log(`第 ${i + 1} 页 (${doc.pageContent.length} 字符)`);
  console.log(doc.metadata); // 包含 pageNumber、source 等信息
}

PDF 加载的核心挑战在于其本质是一种追求“视觉呈现精确”的页面描述语言,而非为机器读取设计的结构化数据。在实际解析时,文本往往混杂着难以提取的扫描件图片、字体编码乱码以及复杂的排版元素,导致多栏布局串栏、表格结构丢失和标题层级消失;同时,段落或代码块极易被分页符拦腰截断,加上大文件处理时面临的内存溢出风险,使得将这种非结构化文档精准转化为 RAG 系统所需的语义内容,成为文档加载中最棘手的环节。

(1)表格丢失

PDF 中的表格在提取时通常会变成混乱的文本,列之间的对齐关系完全丢失。例如:

原始表格:
| 产品   | 价格 | 库存 |
|--------|------|------|
| iPhone | 999  | 100  |

提取后可能变成:
产品 价格 库存 iPhone 999 100

这对于需要精确理解表格内容的场景是灾难性的。

(2)多栏布局错位

学术论文、技术文档常用双栏布局。简单的文本提取会按从左到右、从上到下的顺序读取,导致两栏内容交错:

左栏第一行 右栏第一行
左栏第二行 右栏第二行

(3)扫描件不可解析

如果 PDF 是扫描生成的(即本质上是图片),普通的文本提取器无法工作。这种情况下需要使用 OCR(光学字符识别)技术,如 Tesseract 或商业 OCR 服务。

⚠️ 注意

如果你的知识库大量依赖 PDF,考虑引入专门的 PDF 解析服务(如 Azure Document Intelligence、Unstructured.io、Adobe PDF Extract API),而非仅依赖开源库的简单文本提取。这些服务能更好地处理表格、图表和复杂布局。

推荐的 PDF 解析方案对比

方案核心优势核心劣势适用场景
pdf-parse (开源库)轻量、零依赖 安装即用,适合快速提取纯文本。版面分析弱 无法处理表格、多栏布局,容易乱序。纯文字文档、简单的电子书、对格式无要求的场景。
PyMuPDF (fitz) (开源库)速度极快、功能全 支持提取文字坐标、图片、字体,社区活跃。代码复杂度高 需要自己写逻辑来重组段落和处理表格。需要提取文字坐标(如高亮定位)、对性能要求极高的场景。
Unstructured (开源/API)版面还原强 能识别标题、表格、叙事文本,支持“清洗”功能。依赖重 本地部署需要安装 libmagic 等系统依赖,稍显繁琐。RAG 首选。需要保留文档结构(标题/表格)的复杂文档。
Azure Document Intelligence (云服务)业界最强 对复杂表格、发票、表单的识别率极高,支持布局分析。成本高 按页收费,数据需出网(隐私考量)。企业级应用。处理财务报表、合同、扫描件等“硬骨头”。
PyPDF2 / pypdf (开源库)老牌稳定 主要用于 PDF 的合并、拆分、加密解密。提取能力弱 文本提取效果一般,不支持现代布局分析。主要用于 PDF 文件处理(如加水印、合并),而非内容提取。
PaddleOCR / RapidOCR (OCR引擎)中文识别最强 专门解决扫描件、图片型 PDF 的文字识别问题。速度慢 需要逐页渲染图片并识别,计算资源消耗大。扫描件/图片型 PDF。当其他方案提取出来是空的时候用它。

3. 加载 CSV

CSV 是结构化数据的代表。与 Markdown/PDF 不同,CSV 的每一行都是一条独立的记录,需要决定如何将它们映射为 Document。

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

// 方式一:指定某一列作为 pageContent
const loader1 = new CSVLoader("./data/products.csv", {
  column: "description",  // 用 description 列作为文档内容
});

const docs1 = await loader1.load();
// 每个 Document 的 pageContent 是 description 列的值
// metadata 包含其他列(id、name、price 等)

// 方式二:不指定 column,将所有列拼接为 pageContent
const loader2 = new CSVLoader("./data/products.csv");

const docs2 = await loader2.load();
// pageContent 是所有列的键值对拼接

CVS的典型应用场景有:

  • 产品目录:每一行是一个产品,description 作为 pageContent,其他字段(价格、类别、品牌)作为 metadata
  • FAQ 库:每一行是一个问答对,question + answer 作为 pageContent,category 作为 metadata
  • 员工信息:每一行是一个员工,岗位职责作为 pageContent,部门、职位作为 metadata

同样需要注意的是CSV 文件可能有编码问题(GBK、GB2312 等),特别是中文数据。加载时需要指定正确的编码。对于大文件(> 100MB)可能导致内存溢出,需要考虑流式处理或分批加载。同时,对于空值和特殊字符需要妥善处理。

// 处理编码问题
import { readFileSync } from "fs";
import { iconv } from "iconv-lite";

const rawContent = readFileSync("./data/products_gbk.csv");
const utf8Content = iconv.decode(rawContent, "GBK");

// 将转换后的内容写入临时文件,再用 CSVLoader 加载

4. 加载 HTML

HTML 是网页的标准格式,但其中包含大量噪声(导航栏、广告、脚本等),需要提取正文内容。LangChain社区里提供了CheerioWebBaseLoader用于加载HTML。

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

const loader = new CheerioWebBaseLoader("https://example.com/article");
const docs = await loader.load();

console.log(docs[0].pageContent); // 提取的正文内容

HTML 解析也是有一定挑战的,导航菜单、侧边栏、广告、评论区等非正文的内容混入产生噪声,由JavaScript 渲染的页面动态生成的内容,静态 HTML 提取器则无法获取完整内容。同时,有些网站有反爬虫机制,如频率限制、验证码等防护等。

常见的解决方案是使用专门的正文提取库(如 readabilitynewspaper),对于动态页面,使用 Puppeteer 等浏览器自动化工具提取。而对于反爬机制,则应遵守网站的 robots.txt 和使用合理的请求频率。

5. 各类格式的加载风险一览

格式解析难度主要风险建议
Markdown几乎没有TextLoader 即可
TXT编码问题检测编码后加载
CSV编码问题(BOM 头)、大文件内存溢出大文件改用流式加载
JSON嵌套结构需要展平自定义解析逻辑
HTML导航栏、广告等噪声文本混入配合 HTML 正文提取库
Word (.docx)嵌入图片、修订痕迹混入优先导出为 Markdown 再加载
PDF表格丢失、多栏错位、扫描件不可解析评估专用 PDF 解析服务
PPTX每页内容碎片化、图表难以提取手动整理为 Markdown
图片/扫描件极高需要 OCR,精度不稳定商业 OCR 服务

三、编写自定义文档加载器

当数据源不是文件而是 REST API、数据库或消息队列时,你需要自己实现加载逻辑。LangChain 提供了 BaseDocumentLoader 基类,你只需要继承它并实现 load() 方法即可。

1. 从 REST API 加载数据

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

interface APILoaderOptions {
  url: string;
  headers?: Record<string, string>;
  pageSize?: number;    // 分页大小
  maxPages?: number;    // 最大页数
}

class APIDocumentLoader extends BaseDocumentLoader {
  private options: APILoaderOptions;

  constructor(options: APILoaderOptions) {
    super();
    this.options = options;
  }

  async load(): Promise<Document[]> {
    const allDocs: Document[] = [];
    let page = 1;
    const maxPages = this.options.maxPages || 10;
    
    while (page <= maxPages) {
      const url = `${this.options.url}?page=${page}&limit=${this.options.pageSize || 100}`;
      
      console.log(`正在加载第 ${page} 页...`);
      const response = await fetch(url, {
        headers: this.options.headers,
      });
      
      if (!response.ok) {
        throw new Error(`API 请求失败: ${response.status} ${response.statusText}`);
      }
      
      const data = await response.json();
      
      // 将 API 返回的每条记录映射为一个 Document
      const docs = data.items.map((item: any) => {
        return new Document({
          pageContent: `标题: ${item.title}\n\n内容: ${item.body}`,
          metadata: {
            source: this.options.url,
            id: item.id,
            title: item.title,
            updatedAt: item.updated_at,
            author: item.author?.name || "未知",
          },
        });
      });
      
      allDocs.push(...docs);
      
      // 判断是否还有下一页
      if (!data.hasMore || docs.length === 0) {
        break;
      }
      
      page++;
      
      // 避免触发 API 限流
      await sleep(100);
    }
    
    console.log(`总共加载了 ${allDocs.length} 条记录`);
    return allDocs;
  }
}

function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 使用示例
const loader = new APIDocumentLoader({
  url: "https://api.example.com/articles",
  headers: {
    "Authorization": "Bearer YOUR_TOKEN",
  },
  pageSize: 50,
  maxPages: 20,
});

const docs = await loader.load();

关键设计要点

  • 分页处理:大多数 API 都有分页限制,需要循环请求直到获取所有数据
  • 错误处理:网络请求可能失败,需要有重试机制和超时控制
  • 速率限制:避免频繁请求触发 API 限流,适当添加延迟
  • 进度反馈:大规模加载时,输出进度信息方便监控

2. 从数据库加载数据

import { BaseDocumentLoader } from "langchain/document_loaders/base";
import { Document } from "@langchain/core/documents";
import { Pool } from "pg"; // PostgreSQL 客户端

interface DBLoaderOptions {
  connectionString: string;
  query: string;
  contentColumn: string;   // 作为 pageContent 的列名
  metadataColumns: string[]; // 作为 metadata 的列名
}

class DatabaseDocumentLoader extends BaseDocumentLoader {
  private options: DBLoaderOptions;

  constructor(options: DBLoaderOptions) {
    super();
    this.options = options;
  }

  async load(): Promise<Document[]> {
    const pool = new Pool({ connectionString: this.options.connectionString });
    
    try {
      const result = await pool.query(this.options.query);
      
      return result.rows.map(row => {
        const metadata: Record<string, any> = {};
        
        // 提取 metadata 字段
        for (const col of this.options.metadataColumns) {
          metadata[col] = row[col];
        }
        
        return new Document({
          pageContent: row[this.options.contentColumn],
          metadata: {
            ...metadata,
            source: "database",
            table: this.extractTableName(this.options.query),
          },
        });
      });
    } finally {
      await pool.end();
    }
  }
  
  private extractTableName(query: string): string {
    // 简单提取表名(实际项目中可能需要更复杂的 SQL 解析)
    const match = query.match(/FROM\s+(\w+)/i);
    return match ? match[1] : "unknown";
  }
}

// 使用示例
const loader = new DatabaseDocumentLoader({
  connectionString: process.env.DATABASE_URL!,
  query: "SELECT id, title, content, created_at FROM articles WHERE status = 'published'",
  contentColumn: "content",
  metadataColumns: ["id", "title", "created_at"],
});

const docs = await loader.load();

3. 从 Confluence 加载文档

Confluence 是企业常用的 Wiki 平台,提供了完善的 REST API:

class ConfluenceLoader extends BaseDocumentLoader {
  private baseUrl: string;
  private spaceKey: string;
  private username: string;
  private apiToken: string;

  constructor(options: {
    baseUrl: string;
    spaceKey: string;
    username: string;
    apiToken: string;
  }) {
    super();
    this.baseUrl = options.baseUrl;
    this.spaceKey = options.spaceKey;
    this.username = options.username;
    this.apiToken = options.apiToken;
  }

  async load(): Promise<Document[]> {
    const auth = Buffer.from(`${this.username}:${this.apiToken}`).toString('base64');
    
    // 获取空间中的所有页面
    const pagesResponse = await fetch(
      `${this.baseUrl}/rest/api/content?spaceKey=${this.spaceKey}&expand=body.storage`,
      {
        headers: {
          "Authorization": `Basic ${auth}`,
        },
      }
    );
    
    const pagesData = await pagesResponse.json();
    
    return pagesData.results.map((page: any) => {
      // 去除 HTML 标签,提取纯文本
      const plainText = page.body.storage.value
        .replace(/<[^>]*>/g, '')
        .replace(/\s+/g, ' ')
        .trim();
      
      return new Document({
        pageContent: plainText,
        metadata: {
          source: `${this.baseUrl}/wiki/spaces/${this.spaceKey}/pages/${page.id}`,
          title: page.title,
          pageId: page.id,
          createdAt: page.history.createdDate.when,
          createdBy: page.history.createdBy.displayName,
        },
      });
    });
  }
}

// 使用示例
const loader = new ConfluenceLoader({
  baseUrl: "https://your-company.atlassian.net",
  spaceKey: "DEV",
  username: "your-email@example.com",
  apiToken: "YOUR_API_TOKEN",
});

const docs = await loader.load();

4. 自定义加载器的最佳实践

加载器的实现应该做容错处理、日志监控,保证扩展性和灵活性。

(1)统一的错误处理

async load(): Promise<Document[]> {
  try {
    // 加载逻辑
  } catch (error) {
    if (error instanceof TimeoutError) {
      console.error("加载超时,请检查网络连接连接");
    } else if (error instanceof AuthError) {
      console.error("认证失败,请检查凭证");
    } else {
      console.error("加载失败:", error.message);
    }
    throw error; // 或返回空数组,取决于你的策略
  }
}

(2)日志和监控

async load(): Promise<Document[]> {
  const startTime = Date.now();
  console.log(`开始加载文档...`);
  
  // 加载逻辑
  const docs = await this.fetchDocuments();
  
  const duration = Date.now() - startTime;
  console.log(`加载完成: ${docs.length} 个文档,耗时 ${duration}ms`);
  
  return docs;
}

(3)可配置的加载策略

interface LoaderConfig {
  batchSize?: number;      // 批处理大小
  retryTimes?: number;     // 重试次数
  timeout?: number;        // 超时时间
  filters?: Record<string, any>; // 过滤条件
}

class ConfigurableLoader extends BaseDocumentLoader {
  private config: LoaderConfig;
  
  constructor(config: LoaderConfig) {
    super();
    this.config = {
      batchSize: 100,
      retryTimes: 3,
      timeout: 30000,
      ...config,
    };
  }
  
  // 使用配置
}

💡 小贴士

metadata 的字段设计要在加载阶段就规划好。在《第9章:高级检索策略》中会讲到 Metadata Filtering,如果你在加载时没有记录 datecategory 等字段,后续检索时就无法按这些维度过滤。

四、文档加载的性能优化

1. 并行加载

对于大规模文档库,可以考虑并行加载:

async function loadInParallel(filePaths: string[], concurrency: number = 5): Promise<Document[]> {
  const allDocs: Document[] = [];
  
  // 使用信号量控制并发数
  const semaphore = new Semaphore(concurrency);
  
  const promises = filePaths.map(async (filePath) => {
    await semaphore.acquire();
    try {
      const loader = new TextLoader(filePath);
      const docs = await loader.load();
      return docs;
    } finally {
      semaphore.release();
    }
  });
  
  const results = await Promise.all(promises);
  return results.flat();
}

// 信号量实现
class Semaphore {
  private count: number;
  private queue: Array<() => void> = [];
  
  constructor(count: number) {
    this.count = count;
  }
  
  acquire(): Promise<void> {
    if (this.count > 0) {
      this.count--;
      return Promise.resolve();
    }
    
    return new Promise(resolve => {
      this.queue.push(resolve);
    });
  }
  
  release(): void {
    this.count++;
    const next = this.queue.shift();
    if (next) {
      this.count--;
      next();
    }
  }
}

2. 增量加载

如果文档库很大,全量加载耗时过长,可以实现增量加载:

async function incrementalLoad(
  lastSyncTime: Date,
  outputPath: string
): Promise<{ newDocs: Document[]; updatedDocs: Document[] }> {
  // 只加载上次同步后修改的文件
  const files = getModifiedFilesAfter(lastSyncTime);
  
  const newDocs: Document[] = [];
  const updatedDocs: Document[] = [];
  
  for (const file of files) {
    const loader = new TextLoader(file.path);
    const docs = await loader.load();
    
    if (file.isNew) {
      newDocs.push(...docs);
    } else {
      updatedDocs.push(...docs);
    }
  }
  
  return { newDocs, updatedDocs };
}

3. 缓存机制

对于加载成本高的数据源(如 API 调用),可以引入缓存:

import NodeCache from "node-cache";

const loaderCache = new NodeCache({ stdTTL: 3600 }); // 缓存 1 小时

async function cachedLoad(url: string): Promise<Document[]> {
  const cacheKey = `loader:${url}`;
  
  // 检查缓存
  const cached = loaderCache.get(cacheKey);
  if (cached) {
    console.log("使用缓存数据");
    return cached as Document[];
  }
  
  // 加载数据
  const loader = new APIDocumentLoader({ url });
  const docs = await loader.load();
  
  // 写入缓存
  loaderCache.set(cacheKey, docs);
  
  return docs;
}

FAQ

Q1:文件太多,加载太慢怎么办?

对于大规模文档库,可以考虑并行加载。LangChain 的 BaseDocumentLoader 本身是单文件加载,你可以在外部用 Promise.all() 并发调用多个 loader 实例。注意控制并发数,避免过载本地 I/O 或 API 限流。

另外,评估是否真的需要全量加载。很多时候,只有部分文档是新增或更新的,可以实现增量加载机制。

Q2:加载时发现编码乱码怎么办?

中文文档尤其容易出现 GBK/GB2312 编码的文件。TextLoader 默认使用 UTF-8,遇到乱码时可以借助 jschardeticonv-lite 做编码检测和转换:

import { detect } from "jschardet";
import { decode } from "iconv-lite";
import { readFileSync } from "fs";

const buffer = readFileSync("./docs/chinese_doc.txt");
const detected = detect(buffer);
console.log(`检测到编码: ${detected.encoding}, 置信度: ${detected.confidence}`);

if (detected.encoding !== "UTF-8") {
  const utf8Content = decode(buffer, detected.encoding);
  // 用转换后的内容创建 Document
}

Q3:企业知识库中有大量需要登录才能访问的内部页面怎么办?

这就是自定义加载器的典型用武之地。在 headers 中传入 Cookie 或 Token,处理登录态过期后的自动续期逻辑——这些都可以封装在你的 APIDocumentLoader 中。

对于需要 OAuth 认证的 API,可以使用 simple-oauth2 等库管理令牌刷新:

import { ClientCredentials } from "simple-oauth2";

const oauth2 = new ClientCredentials({
  client: { id: "CLIENT_ID", secret: "CLIENT_SECRET" },
  auth: { tokenHost: "https://auth.example.com" },
});

async function getAccessToken(): Promise<string> {
  const result = await oauth2.getToken();
  return result.token.access_token;
}

Q4:如何处理超大文件(> 100MB)?

超大文件直接加载会导致内存溢出。解决方案包括:

  • 流式处理:逐行或逐块读取文件,而不是一次性加载到内存
  • 分批处理:将大文件拆分为多个小文件,分别加载后再合并
  • 预处理:在加载前先用工具(如 split 命令)将大文件拆分

Q5:PDF 中的图片和图表怎么处理?

普通 PDF 加载器无法提取图片和图表。如果需要处理这些内容,有以下选择:

  • OCR 服务:使用 Azure Computer Vision、Google Cloud Vision 等 OCR 服务提取图片中的文字
  • 专门工具:使用 Unstructured.io 等专业文档解析服务,它们能更好地处理复杂布局
  • 人工标注:对于关键文档,可以手动将图片和图表的内容整理为文本描述

练习

练习 1:加载多种格式文件

在同一目录下准备一个 Markdown 文件、一个 PDF 文件和一个 CSV 文件,用对应的 Loader 加载并打印每个 Document 的 metadata,观察它们的差异。

验证标准

  • 每种格式成功加载
  • metadata 结构因格式不同而有合理差异(如 PDF 包含 pageNumber,CSV 包含列名)
  • 能够正确访问 pageContent 和 metadata 字段

练习 2:实现一个 GitHub Issues Loader

使用 GitHub REST API(https://api.github.com/repos/{owner}/{repo}/issues),实现一个自定义 Loader,将每一条 Issue 加载为一个 Document(pageContent = title + body,metadata 包含 issue_number、state、labels 等)。

提示

  • GitHub API 需要认证,可以创建 Personal Access Token
  • Issues API 支持分页,需要处理多页数据
  • 注意 API 速率限制(未认证用户每小时 60 次请求)

验证标准

  • load() 返回的数组长度与指定仓库的 Issue 数量一致
  • 每个 Document 的 metadata 包含必要的字段
  • 能够处理空 body 的 Issue

练习 3:实现增量加载器

编写一个增量加载器,只加载上次同步后修改的文件。使用文件系统的时间戳来判断文件是否更新。

提示

  • 维护一个 lastSyncTime 变量,记录上次同步时间
  • 遍历目录时,比较文件的 mtime(修改时间)与 lastSyncTime
  • 只加载 mtime > lastSyncTime 的文件

验证标准

  • 首次运行时加载所有文件
  • 修改部分文件后再次运行,只加载修改过的文件
  • 输出加载的文件列表和数量

练习 4:处理编码问题

准备一个 GBK 编码的中文文本文件,编写代码检测其编码并正确加载。

验证标准

  • 能够自动检测文件编码
  • 加载后的内容没有乱码
  • 能够正确处理 UTF-8 和 GBK 两种编码的文件

📚 延伸阅读

  • LangChain Document Loaders — LangChain 官方文档,介绍了各种内置加载器和自定义加载器的开发方法
  • Unstructured.io — 专业的非结构化文档解析服务,支持 PDF、Word、PPT 等多种格式,能更好地处理表格和复杂布局
  • Cheerio — 快速灵活的 HTML 解析库,适合从网页中提取结构化数据
  • jschardet — JavaScript 版的字符编码检测库,基于 Mozilla 的通用字符集检测算法
  • GitHub API Documentation — GitHub REST API 完整文档,包含认证、分页、速率限制等重要信息