课程目标
精读 RAG 管道中的两个关键环节:TextSplitter 的文本分块策略(RecursiveCharacterTextSplitter、CharacterTextSplitter)和 Embeddings 的向量化接口(embedQuery、embedDocuments)。理解分块参数对检索质量的影响。
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[]>;
}
三大参数:
| 参数 | 默认值 | 含义 |
|---|---|---|
chunkSize | 1000 | 每个分块的最大长度 |
chunkOverlap | 200 | 相邻分块之间的重叠长度 |
keepSeparator | false | 分割后是否保留分隔符 |
重叠的作用:确保跨分块边界的信息不会完全丢失。如果一句话被分割到两个 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;
}
}
递归策略:
- 先尝试
\n\n(段落级别) - 如果段落仍然太大,用
\n(行级别) - 如果行仍然太大,用
(词级别) - 最后用
""(字符级别)
编程语言特化分割
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 源码精读路线
| 优先级 | 文件 | 关注点 |
|---|---|---|
| P0 | langchain-textsplitters/src/text_splitter.ts:20-228 | TextSplitter 基类 — 参数定义和 mergeSplits 算法 |
| P0 | langchain-textsplitters/src/text_splitter.ts:282-350 | RecursiveCharacterTextSplitter._splitText() — 递归分割核心 |
| P0 | langchain-core/src/embeddings.ts | Embeddings 抽象类 — embedQuery + embedDocuments |
| P1 | langchain-textsplitters/src/text_splitter.ts:234-254 | CharacterTextSplitter — 简单分割器 |
| P1 | langchain-textsplitters/src/text_splitter.ts:75-160 | createDocuments() — 分块后保留 metadata 和行号 |
| P2 | langchain-textsplitters/src/text_splitter.ts:351-400 | fromLanguage() 和各语言分隔符配置 |
本课收获总结
| 级别 | 你应该掌握的 |
|---|---|
| 🟢 基础 | 理解 RAG 的基本流程:加载 → 分割 → 嵌入 → 存储 → 检索 → 生成 |
| 🔵 中阶 | 掌握 RecursiveCharacterTextSplitter 的分块策略和参数含义 |
| 🟡 高阶 | 理解 Embeddings 接口的 embedQuery 与 embedDocuments 的设计原因 |
| 🟠 资深 | 分析分块大小对检索质量的影响;掌握 mergeSplits 的滑动窗口算法 |
| 🔴 架构 | 设计大规模文档处理的批量 pipeline:编程语言特化分割 + 自定义长度函数 + 异步 Embeddings |
下一课预告
第 31 课进入 VectorStore 与 Retriever —— 理解向量数据库抽象、相似度搜索和 BaseRetriever 如何作为 Runnable 嵌入 RAG 链。