上节我们学了 loader 和 splitter:知识可能有各种来源,比如一个视频、一个 pdf、一个网页、一个 word 文档,这时候就需要通过各种 loader 从中提取信息,把它们转换成 Document。但是 Document 可能会很大,需要用 Splitter 分割成一个个比较小的 Document(chunk),之后用嵌入模型,把分块的文档向量化后存入向量数据库。
这节我们把所有的 Splitter 过一遍,看看它们有什么区别、该怎么选。
separator 和 chunk size 的概念
首先区分 separator 和 chunk size 的概念。
比如上节我们这样分割 Document:首先按照 。 的 separator 来分割字符串,然后按照 chunk size 放入一个个 Document。
如果分割后还是大于 chunk size,就需要按照后面的 separator 继续分割,然后加上 overlap。
注意:overlap 只有文本超过 chunk size、文本被打断了才会加,不是所有的块都会有 overlap。比如上面那段话超过了 chunk size,分割到两个 chunk 里,第二个 chunk 就会按照设置重复一部分内容。
设置 overlap 是为了保证语义连贯性,通常设置为 chunkSize 的 10% - 20%。牺牲了一点存储空间(因为数据重复了),换取了模型对上下文理解的完整性。
所有 Splitter 一览
我们看下 @langchain/textsplitters 这个包导出的 splitter,以及它们的继承关系:
- 所有的 Splitter 都继承自
TextSplitter,包括RecursiveCharacterTextSplitter等 - 而
MarkdownTextSplitter、LatexTextSplitter又继承自RecursiveCharacterTextSplitter
其实很容易理解:
CharacterTextSplitter是按照某个字符来分割,比如按照句号RecursiveCharacterTextSplitter是递归分割,比如"。?!"——先尝试按照。分割,如果分割后大于 chunk 剩余空间再按照?分割,是一个递归过程MarkdownTextSplitter自然就是按照#、##、###等一级级标题来递归分割,所以是RecursiveCharacterTextSplitter的子类- Latex 是写数学公式的语法(
https://www.latexlive.com/),它和 markdown 一样,自然也是递归按照某些字符分割的,所以也是继承自RecursiveCharacterTextSplitter
那 TokenTextSplitter 呢?这是另一种分割策略。
为什么需要按 Token 分割
我们按照字符分割,分割出来的文档的 token 大小是不一定的。token 是大模型输入的一个单位,可能一个单词是 1 到 2 个 token:apple 是 1 个 token,pineapple 是 2 个 token,苹果 是 1-2 个 token。
我们试一下,用 js-tiktoken 这个包(它是 OpenAI 模型的分词器):
pnpm install js-tiktoken创建 src/tiktoken-test.mjs:
import { getEncoding, getEncodingNameForModel } from "js-tiktoken";
const modelName = "gpt-4";
const encodingName = getEncodingNameForModel(modelName);
console.log(encodingName);
const enc = getEncoding("cl100k_base");
console.log('apple', enc.encode("apple").length);
console.log('pineapple', enc.encode("pineapple").length);
console.log('苹果', enc.encode("苹果").length);
console.log('吃饭', enc.encode("吃饭").length);
console.log('一二三', enc.encode("一二三").length);可以看到,字符和 token 数量并没有一个确定的关系,与不同模型的分词器有关。这样我们按照字符数来计算 chunk size 就没法准确估算 token 大小,对于需要精准控制 token 数量的场景就不太合适了。
这时候就可以用 TokenTextSplitter,它是按照 token 数来分割的。
CharacterTextSplitter:按字符分割
先试一下 CharacterTextSplitter,创建 src/CharacterTextSplitter-test.mjs:
import "dotenv/config";
import "cheerio";
import { CharacterTextSplitter } from "@langchain/textsplitters";
import { Document } from "@langchain/core/documents";
import { getEncoding } from "js-tiktoken";
const logDocument = new Document({
pageContent: `[2024-01-15 10:00:00] INFO: Application started
[2024-01-15 10:00:05] DEBUG: Loading configuration file
[2024-01-15 10:00:10] INFO: Database connection established
[2024-01-15 10:00:15] WARNING: Rate limit approaching
[2024-01-15 10:00:20] ERROR: Failed to process request
[2024-01-15 10:00:25] INFO: Retrying operation
[2024-01-15 10:00:30] SUCCESS: Operation completed`
});
const logTextSplitter = new CharacterTextSplitter({
separator: '\n',
chunkSize: 200,
chunkOverlap: 20
});
const splitDocuments = await logTextSplitter.splitDocuments([logDocument]);
const enc = getEncoding("cl100k_base");
splitDocuments.forEach(document => {
console.log(document);
console.log('character length:', document.pageContent.length);
console.log('token length:', enc.encode(document.pageContent).length);
});一段日志文本,按照换行符来分割,每个块 200 字符。跑一下可以看到,按照换行符分割文本,然后按照 chunk size 放到了 3 个块里。
有同学可能会问,chunk 的大小也没有到 200 啊?因为 splitter 会优先保证语义完整,宁愿 chunk 小一点。这里到了 160 左右字符的时候,发现加上下一个文本就超过 200 了,所以会放到下一个块。而且这里因为没有断开的文本,所以就没有需要加 overlap 重复的——只有被断开的文本才有 overlap。
我们加一个长的文本试一下(在上面文档末尾加一条中文长日志),会发现问题了:
CharacterTextSplitter 非常死板——你告诉它按照换行符分割,它就会严格按照这个,就算超过了 chunk size 也不拆分。所以一般还是用 RecursiveCharacterTextSplitter。
RecursiveCharacterTextSplitter:递归分割
创建 src/RecursiveCharacterTextSplitter-test.mjs:
import "dotenv/config";
import "cheerio";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { Document } from "@langchain/core/documents";
import { getEncoding } from "js-tiktoken";
const logDocument = new Document({
pageContent: `[2024-01-15 10:00:00] INFO: Application started
[2024-01-15 10:00:05] DEBUG: Loading configuration file
[2024-01-15 10:00:10] INFO: Database connection established
[2024-01-15 10:00:15] WARNING: Rate limit approaching
[2024-01-15 10:00:20] ERROR: Failed to process request
[2024-01-15 10:00:25] INFO: Retrying operation
[2024-01-15 10:00:30] SUCCESS: Operation completed
[2026-01-10 14:30:00] INFO: 系统开始执行大规模数据迁移任务,本次迁移涉及核心业务数据库中的用户表、订单表...`
});
const logTextSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 150,
chunkOverlap: 20,
separators: ['\n', '。', ',']
});
const splitDocuments = await logTextSplitter.splitDocuments([logDocument]);
const enc = getEncoding("cl100k_base");
splitDocuments.forEach(document => {
console.log(document);
console.log('character length:', document.pageContent.length);
console.log('token length:', enc.encode(document.pageContent).length);
});它可以指定多个分隔符:当 \n 分割后还是大,就会用 。,还是不行再尝试用 ,。这样就明显好很多——按照换行符分割后下面的文本超过 chunk size,就会尝试按照句号逗号分割,然后加上 overlap;最后按照逗号分隔的也没超过 chunk size,就没有 overlap 了。
所以说 RecursiveCharacterTextSplitter 这种递归的方式灵活太多了,绝大多数情况下,用这个就可以了。
TokenTextSplitter:按 Token 分割
创建 src/TokenTextSplitter-test.mjs:
import "dotenv/config";
import "cheerio";
import { TokenTextSplitter } from "@langchain/textsplitters";
import { Document } from "@langchain/core/documents";
import { getEncoding } from "js-tiktoken";
const logDocument = new Document({
pageContent: `[2024-01-15 10:00:00] INFO: Application started
[2024-01-15 10:00:05] DEBUG: Loading configuration file
[2024-01-15 10:00:10] INFO: Database connection established
[2024-01-15 10:00:15] WARNING: Rate limit approaching
[2024-01-15 10:00:20] ERROR: Failed to process request
[2024-01-15 10:00:25] INFO: Retrying operation
[2024-01-15 10:00:30] SUCCESS: Operation completed`
});
const logTextSplitter = new TokenTextSplitter({
chunkSize: 50, // 每个块最多 50 个 Token
chunkOverlap: 10, // 块之间重叠 10 个 Token
encodingName: 'cl100k_base', // OpenAI 使用的编码方式
});
const splitDocuments = await logTextSplitter.splitDocuments([logDocument]);
const enc = getEncoding("cl100k_base");
splitDocuments.forEach(document => {
console.log(document);
console.log('character length:', document.pageContent.length);
console.log('token length:', enc.encode(document.pageContent).length);
});可以看到,它优先保证 token 正好是 50,为了这个不惜强行打断文本。当然,打断后也加了 overlap。
对比一下:
RecursiveCharacterTextSplitter分出的 chunk 可能大于 chunk size,也可以小于,优先保证语义完整,是按照分割符来分割的TokenTextSplitter不是,它只会保证 token 数量
这种不管不顾的分割显然不靠谱,不一定在什么地方就断开了。还是 RecursiveCharacterTextSplitter 那种更科学。
终极方案:重写 lengthFunction
那能不能用 RecursiveCharacterTextSplitter 的分割方式,然后按照 token 长度来设置 chunk size 呢?
可以的——重写它的长度计算函数就可以了,这样 chunk size 指的就是 token 的长度:
const enc = getEncoding("cl100k_base");
const logTextSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 150,
chunkOverlap: 20,
separators: ['\n', '。', ','],
lengthFunction: (text) => enc.encode(text).length,
});现在就是按照 token 数量作为分割依据了,完全不需要用 TokenTextSplitter。
Markdown / Latex / 代码的分割
最后再来看一下 markdown、latex、代码的分割。其实这些很明显,都是 RecursiveCharacterTextSplitter 实现的:
- markdown 是按照
#、##、###的子标题来递归分割 - latex 是按照那些数学公式的语法来分割
- 代码则是分语言来用不同的分割符
但总体来说都是递归分割,所以他们都是用 RecursiveCharacterTextSplitter 实现的。我们快速测一下。
Markdown 分割
创建 src/recursive-splitter-markdown.mjs:
import "dotenv/config";
import "cheerio";
import { Document } from "@langchain/core/documents";
import { MarkdownTextSplitter } from "@langchain/textsplitters";
const readmeText = `# Project Name
> A brief description of your project
## Features
- Feature 1
- Feature 2
- Feature 3
## Installation
\`\`\`bash
npm install project-name
\`\`\`
## Usage
### Basic Usage
\`\`\`javascript
import { Project } from 'project-name';
const project = new Project();
project.init();
\`\`\`
`;
const readmeDoc = new Document({
pageContent: readmeText
});
const markdownTextSplitter = new MarkdownTextSplitter({
chunkSize: 400,
chunkOverlap: 80
});
const splitDocuments = await markdownTextSplitter.splitDocuments([readmeDoc]);
splitDocuments.forEach(document => {
console.log(document);
console.log('character length:', document.pageContent.length);
});创建 MarkdownTextSplitter,不用指定分割符,内置了。跑一下可以看到,都是从标题处断开的,也就是根据语法分割的。
Latex 分割
创建 src/recursive-splitter-latex.mjs:
import "dotenv/config";
import "cheerio";
import { Document } from "@langchain/core/documents";
import { LatexTextSplitter } from "@langchain/textsplitters";
const latexText = `\int x^{\mu}\mathrm{d}x=\frac{x^{\mu +1}}{\mu +1}+C, \left({\mu \neq -1}\right)
\begin{pmatrix}
a_{11} & a_{12} & a_{13} \\
a_{21} & a_{22} & a_{23} \\
a_{31} & a_{32} & a_{33}
\end{pmatrix}`;
const latexDoc = new Document({
pageContent: latexText
});
const latexTextSplitter = new LatexTextSplitter({
chunkSize: 200,
chunkOverlap: 40
});
const splitDocuments = await latexTextSplitter.splitDocuments([latexDoc]);
splitDocuments.forEach(document => {
console.log(document);
console.log('character length:', document.pageContent.length);
});也是按照正确的语法分割的。
代码分割
创建 src/recursive-splitter-code.mjs:
import "dotenv/config";
import "cheerio";
import { Document } from "@langchain/core/documents";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const jsCode = `// Complete shopping cart implementation
class Product {
constructor(id, name, price, description) {
this.id = id;
this.name = name;
this.price = price;
this.description = description;
}
getFormattedPrice() {
return '$' + this.price.toFixed(2);
}
}
class ShoppingCart {
constructor() {
this.items = [];
this.discountCode = null;
this.taxRate = 0.08;
}
addItem(product, quantity = 1) {
const existingItem = this.items.find(item => item.product.id === product.id);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.push({ product, quantity, addedAt: new Date() });
}
return this;
}
calculateTotal() {
const subtotal = this.items.reduce((total, item) => {
return total + (item.product.price * item.quantity);
}, 0);
return subtotal;
}
}
const product1 = new Product(1, 'Laptop', 999.99, 'High-performance laptop');
const cart = new ShoppingCart();
cart.addItem(product1, 1);
console.log('Total:', cart.calculateTotal());`;
const jsCodeDoc = new Document({
pageContent: jsCode
});
const codeSplitter = RecursiveCharacterTextSplitter.fromLanguage('js', {
chunkSize: 300,
chunkOverlap: 60,
});
const splitDocuments = await codeSplitter.splitDocuments([jsCodeDoc]);
splitDocuments.forEach(document => {
console.log(document);
console.log('character length:', document.pageContent.length);
});用 RecursiveCharacterTextSplitter.fromLanguage 这个方法,指定语言,就会按照对应的语法来分割。支持的语言有很多,包括:java、go、js、html、python、rust、swift、markdown 等。
可以看到,完全没有破坏代码完整性,确实是按照语法分割的。
结论:只用 RecursiveCharacterTextSplitter 就够
这样,我们就把所有 splitter 过了一遍。其实看到这里你应该也有答案了:基本就用 RecursiveCharacterTextSplitter 就行。
CharacterTextSplitter的功能RecursiveCharacterTextSplitter里都有TokenTextSplitter严格按照 token,会破坏文档语义,不如RecursiveCharacterTextSplitter重写lengthFunction- 另外两个(Markdown、Latex)则是
RecursiveCharacterTextSplitter的子功能
常见问题
chunk 的大小是怎么计算的? splitter 先按照 separator 来分割,然后按照 chunk size 放到一个个 chunk 里。chunk 的实际大小可能小于 chunk size,也可以大于。如果分割后文本长度大于 chunk size,会继续按照后面的 separator 拆分,然后放到两个 chunk 里,加上 overlap 来保证语义连贯。如果从前到后尝试 separator,尝试到最后一个,拆分完还是大于 chunk size 就不会再拆分了。
什么时候会有 overlap? 只有文本超过 chunk size、文本被强行打断的时候才会加 overlap,不是所有块都有。overlap 通常设置为 chunkSize 的 10% - 20%。
怎么严格控制 token 大小? 默认是按照字符计数,如果想严格控制 token 大小(比如需要计费的场景),就实现 lengthFunction,用 token 的方式计算长度。
给整个代码仓库做 RAG 有什么好办法? chunk 会丢语义、chunk 太大上下文装不下,代码一般用知识图谱来做,后面讲 Neo4j 的那节会介绍。
总结
这节我们把所有 splitter 过了一遍,结论是直接用 RecursiveCharacterTextSplitter 就行:
- splitter 先按照 separator 来分割,然后按照 chunk size 放到一个个 chunk 里
- chunk 的实际大小可能小于 chunk size 也可以大于,splitter 优先保证语义完整
- 分割后文本长度大于 chunk size,会继续按照后面的 separator 拆分,加上 overlap 保证语义连贯
- 默认按字符计数;严格控制 token(计费场景)就实现
lengthFunction用 token 方式计算长度 RecursiveCharacterTextSplitter还支持代码分割,用fromLanguage的静态方法,处理代码文档时很有用
虽然这节讲了很多,但是结论很简单:用 RecursiveCharacterTextSplitter 就好了。
