第 30 课: 文本分割与 Embeddings

3 阅读6分钟

课程目标

精读 RAG 管道中的两个关键环节:TextSplitter 的文本分块策略(RecursiveCharacterTextSplitterCharacterTextSplitter)和 Embeddings 的向量化接口(embedQueryembedDocuments)。理解分块参数对检索质量的影响。


30.1 RAG 全流程回顾

基于第 29 课的 Document 基础,完整的 RAG 流程是:

加载文档 → 文本分割 → 向量化 → 存储 → 检索 → 生成
(第 29 课)   (本课)     (本课)   (第 31 课)    (第 32 课)

本课聚焦文本分割向量化两个步骤。


30.2 TextSplitter — 文本分块基类

源码位置: libs/langchain-textsplitters/src/text_splitter.ts:20

export interface TextSplitterParams {
  chunkSize: number;
  chunkOverlap: number;
  keepSeparator: boolean;
  lengthFunction?: ((text: string) => number) | ((text: string) => Promise<number>);
}

export abstract class TextSplitter
  extends BaseDocumentTransformer
  implements TextSplitterParams {

  lc_namespace = ["langchain", "document_transformers", "text_splitters"];

  chunkSize = 1000;
  chunkOverlap = 200;
  keepSeparator = false;
  lengthFunction: ((text: string) => number) | ((text: string) => Promise<number>);

  constructor(fields?: Partial<TextSplitterParams>) {
    super(fields);
    this.chunkSize = fields?.chunkSize ?? this.chunkSize;
    this.chunkOverlap = fields?.chunkOverlap ?? this.chunkOverlap;
    this.keepSeparator = fields?.keepSeparator ?? this.keepSeparator;
    this.lengthFunction = fields?.lengthFunction ?? ((text: string) => text.length);

    if (this.chunkOverlap >= this.chunkSize) {
      throw new Error("Cannot have chunkOverlap >= chunkSize");
    }
  }

  abstract splitText(text: string): Promise<string[]>;
}

三大参数

参数默认值含义
chunkSize1000每个分块的最大长度
chunkOverlap200相邻分块之间的重叠长度
keepSeparatorfalse分割后是否保留分隔符

重叠的作用:确保跨分块边界的信息不会完全丢失。如果一句话被分割到两个 chunk 中,重叠区域保证它至少完整出现在其中一个 chunk 里。

原文: [AAAA|BBBB|CCCC|DDDD]
chunk 1: [AAAA|BBBB|CC]       ← chunkSize
chunk 2:        [BB|CCCC|DD]overlap = 2 个字符
chunk 3:              [DD|DDDD]

30.3 TextSplitter 的核心方法

30.3.1 splitText — 子类必须实现

将单个文本字符串分割为多个片段。

30.3.2 splitDocuments — 分割文档数组

async splitDocuments(documents: Document[]): Promise<Document[]> {
  const selectedDocuments = documents.filter(doc => doc.pageContent !== undefined);
  const texts = selectedDocuments.map(doc => doc.pageContent);
  const metadatas = selectedDocuments.map(doc => doc.metadata);
  return this.createDocuments(texts, metadatas);
}

30.3.3 createDocuments — 分割并保留 metadata

async createDocuments(
  texts: string[],
  metadatas: Record<string, any>[] = [],
  chunkHeaderOptions: TextSplitterChunkHeaderOptions = {}
): Promise<Document[]> {
  const documents = new Array<Document>();
  for (let i = 0; i < texts.length; i++) {
    for (const chunk of await this.splitText(texts[i])) {
      // 计算行号位置
      const loc = { lines: { from: lineCounterIndex, to: lineCounterIndex + newLinesCount } };
      documents.push(new Document({
        pageContent: chunkHeader + chunk,
        metadata: { ...metadatas[i], loc },
      }));
    }
  }
  return documents;
}

关键特性

  • 原始文档的 metadata 被复制到每个分块
  • 自动计算并附加 loc.lines(行号范围),便于追溯
  • 支持 chunkHeader:为每个分块添加前缀(如 "来自文档 X")

30.3.4 mergeSplits — 合并小片段

async mergeSplits(splits: string[], separator: string): Promise<string[]> {
  const docs: string[] = [];
  const currentDoc: string[] = [];
  let total = 0;

  for (const d of splits) {
    const _len = await this.lengthFunction(d);
    if (total + _len + currentDoc.length * separator.length > this.chunkSize) {
      if (currentDoc.length > 0) {
        docs.push(currentDoc.join(separator).trim());
        // 保留 overlap 部分
        while (total > this.chunkOverlap || ...) {
          total -= await this.lengthFunction(currentDoc[0]);
          currentDoc.shift();
        }
      }
    }
    currentDoc.push(d);
    total += _len;
  }
  return docs;
}

mergeSplits 是分块的核心算法:将多个小片段合并成满足 chunkSize 约束的大块,并在切换时保留 chunkOverlap 的重叠。


30.4 CharacterTextSplitter — 按分隔符分割

最简单的分割器:按单个分隔符切分。

源码位置: libs/langchain-textsplitters/src/text_splitter.ts:234

export class CharacterTextSplitter extends TextSplitter {
  separator = "\n\n";

  constructor(fields?: Partial<CharacterTextSplitterParams>) {
    super(fields);
    this.separator = fields?.separator ?? this.separator;
  }

  async splitText(text: string): Promise<string[]> {
    const splits = this.splitOnSeparator(text, this.separator);
    return this.mergeSplits(splits, this.keepSeparator ? "" : this.separator);
  }
}

适用场景:文本有明确的段落分隔(如 \n\n)时效果好。


30.5 RecursiveCharacterTextSplitter — 递归分割

最常用的分割器。它按优先级尝试多种分隔符,递归地将大文本拆成小块。

源码位置: libs/langchain-textsplitters/src/text_splitter.ts:282

export class RecursiveCharacterTextSplitter extends TextSplitter {
  separators: string[] = ["\n\n", "\n", " ", ""];

  constructor(fields?: Partial<RecursiveCharacterTextSplitterParams>) {
    super(fields);
    this.separators = fields?.separators ?? this.separators;
    this.keepSeparator = fields?.keepSeparator ?? true;
  }

  private async _splitText(text: string, separators: string[]) {
    const finalChunks: string[] = [];

    // 找到第一个在文本中存在的分隔符
    let separator: string = separators[separators.length - 1];
    let newSeparators;
    for (let i = 0; i < separators.length; i++) {
      if (text.includes(separators[i])) {
        separator = separators[i];
        newSeparators = separators.slice(i + 1);
        break;
      }
    }

    const splits = this.splitOnSeparator(text, separator);
    let goodSplits: string[] = [];

    for (const s of splits) {
      if ((await this.lengthFunction(s)) < this.chunkSize) {
        goodSplits.push(s);        // 足够小,暂存
      } else {
        // 当前片段太大 → 先合并暂存的好片段
        if (goodSplits.length) {
          finalChunks.push(...await this.mergeSplits(goodSplits, _separator));
          goodSplits = [];
        }
        // 对大片段用下一级分隔符递归分割
        if (newSeparators) {
          finalChunks.push(...await this._splitText(s, newSeparators));
        } else {
          finalChunks.push(s);
        }
      }
    }

    if (goodSplits.length) {
      finalChunks.push(...await this.mergeSplits(goodSplits, _separator));
    }
    return finalChunks;
  }
}

递归策略

  1. 先尝试 \n\n(段落级别)
  2. 如果段落仍然太大,用 \n(行级别)
  3. 如果行仍然太大,用 (词级别)
  4. 最后用 ""(字符级别)

编程语言特化分割

static fromLanguage(language: SupportedTextSplitterLanguage, options?) {
  return new RecursiveCharacterTextSplitter({
    ...options,
    separators: RecursiveCharacterTextSplitter.getSeparatorsForLanguage(language),
  });
}

支持的语言:cpp, go, java, js, php, proto, python, rst, ruby, rust, scala, swift, markdown, latex, html, sol

每种语言使用语言特定的分隔符,如 Python 使用 \nclass , \ndef , \n\tdef 等。


30.6 Embeddings — 向量化接口

源码位置: libs/langchain-core/src/embeddings.ts

export interface EmbeddingsInterface<TOutput = number[]> {
  embedDocuments(documents: string[]): Promise<TOutput[]>;
  embedQuery(document: string): Promise<TOutput>;
}

export abstract class Embeddings<TOutput = number[]>
  implements EmbeddingsInterface<TOutput> {

  caller: AsyncCaller;

  constructor(params: EmbeddingsParams) {
    this.caller = new AsyncCaller(params ?? {});
  }

  abstract embedDocuments(documents: string[]): Promise<TOutput[]>;
  abstract embedQuery(document: string): Promise<TOutput>;
}

两个核心方法

方法用途调用时机
embedDocuments(docs)批量将文档转为向量索引阶段(写入向量库)
embedQuery(query)将单个查询转为向量检索阶段(搜索向量库)

为什么分两个方法? 某些 Embedding 模型对文档和查询使用不同的编码策略。例如,文档 embedding 可能添加 "passage:" 前缀,查询 embedding 添加 "query:" 前缀。

AsyncCaller 的作用

Embeddings 基类通过 AsyncCaller 内置了并发控制和重试能力:

this.caller = new AsyncCaller(params ?? {});

Provider 实现可以使用 this.caller.call() 来调用 API,自动获得:

  • 并发限制(maxConcurrency
  • 失败重试(maxRetries

30.7 分块参数对检索质量的影响

参数组合效果适用场景
chunkSize=500, overlap=50精细分块,检索精度高技术文档、FAQ
chunkSize=1000, overlap=200平衡分块(默认)通用场景
chunkSize=2000, overlap=400粗粒度分块,保留更多上下文长文分析、论文
chunkSize=200, overlap=20极细分块短句级别的精确匹配

经验法则

  • chunkSize 太小:上下文不足,LLM 难以理解
  • chunkSize 太大:噪声增多,检索精度下降
  • chunkOverlap 一般设为 chunkSize 的 10%-20%

30.8 分块与 metadata 的联动

分割后的每个 chunk 继承原始文档的 metadata,并自动添加位置信息:

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 500,
  chunkOverlap: 50,
});

const docs = [
  new Document({
    pageContent: "很长的文档内容...",
    metadata: { source: "guide.md", chapter: "第一章" },
  }),
];

const chunks = await splitter.splitDocuments(docs);
console.log(chunks[0].metadata);
// {
//   source: "guide.md",
//   chapter: "第一章",
//   loc: { lines: { from: 1, to: 15 } }
// }

30.9 实战练习:完整的分割 + 向量化管道

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

// 步骤 1:准备文档
const docs = [
  new Document({
    pageContent: `
# LangChain.js 简介

LangChain.js 是一个用于构建 LLM 应用的 TypeScript 框架。
它提供了统一的 Runnable 接口,让所有组件都可以通过 pipe() 组合。

## 核心概念

### Runnable
Runnable 是框架的万物基石。每个组件都实现了 invoke/stream/batch 三大方法。

### Chain
Chain 是多个 Runnable 的线性组合:Prompt → Model → Parser。

### Agent
Agent 在 Chain 的基础上引入了循环:LLM 可以决定调用工具并根据结果继续推理。
    `,
    metadata: { source: "introduction.md", language: "zh" },
  }),
];

// 步骤 2:文本分割
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 200,
  chunkOverlap: 30,
});

const chunks = await splitter.splitDocuments(docs);

console.log(`原始文档数: ${docs.length}`);
console.log(`分块后文档数: ${chunks.length}`);
console.log("---");

for (const [i, chunk] of chunks.entries()) {
  console.log(`\n=== Chunk ${i + 1} ===`);
  console.log(`长度: ${chunk.pageContent.length}`);
  console.log(`行号: ${chunk.metadata.loc.lines.from}-${chunk.metadata.loc.lines.to}`);
  console.log(`来源: ${chunk.metadata.source}`);
  console.log(`内容预览: ${chunk.pageContent.slice(0, 80)}...`);
}

// 步骤 3:编程语言特化分割
const jsSplitter = RecursiveCharacterTextSplitter.fromLanguage("js", {
  chunkSize: 300,
  chunkOverlap: 50,
});

const codeDoc = new Document({
  pageContent: `
function hello() {
  console.log("Hello, World!");
}

class Calculator {
  add(a, b) { return a + b; }
  multiply(a, b) { return a * b; }
}

const calc = new Calculator();
console.log(calc.add(2, 3));
  `,
  metadata: { source: "example.js", type: "code" },
});

const codeChunks = await jsSplitter.splitDocuments([codeDoc]);
for (const chunk of codeChunks) {
  console.log(`\n[JS Chunk] ${chunk.pageContent.trim().slice(0, 60)}...`);
}

30.10 源码精读路线

优先级文件关注点
P0langchain-textsplitters/src/text_splitter.ts:20-228TextSplitter 基类 — 参数定义和 mergeSplits 算法
P0langchain-textsplitters/src/text_splitter.ts:282-350RecursiveCharacterTextSplitter._splitText() — 递归分割核心
P0langchain-core/src/embeddings.tsEmbeddings 抽象类 — embedQuery + embedDocuments
P1langchain-textsplitters/src/text_splitter.ts:234-254CharacterTextSplitter — 简单分割器
P1langchain-textsplitters/src/text_splitter.ts:75-160createDocuments() — 分块后保留 metadata 和行号
P2langchain-textsplitters/src/text_splitter.ts:351-400fromLanguage() 和各语言分隔符配置

本课收获总结

级别你应该掌握的
🟢 基础理解 RAG 的基本流程:加载 → 分割 → 嵌入 → 存储 → 检索 → 生成
🔵 中阶掌握 RecursiveCharacterTextSplitter 的分块策略和参数含义
🟡 高阶理解 Embeddings 接口的 embedQueryembedDocuments 的设计原因
🟠 资深分析分块大小对检索质量的影响;掌握 mergeSplits 的滑动窗口算法
🔴 架构设计大规模文档处理的批量 pipeline:编程语言特化分割 + 自定义长度函数 + 异步 Embeddings

下一课预告

第 31 课进入 VectorStore 与 Retriever —— 理解向量数据库抽象、相似度搜索和 BaseRetriever 如何作为 Runnable 嵌入 RAG 链。