RecursiveCharacterTextSplitter 是langchain的切割工具。
最原始的切割是按照字数切割
不管三七二十一,每500个字一切。
js
import { createHash } from 'crypto';
import { Injectable, Logger } from '@nestjs/common';
import { DocumentChunk } from './types/pipeline.types';
@Injectable()
export class SimpleChunkingService {
private readonly logger = new Logger(SimpleChunkingService.name);
// 每500汉字切割
private readonly CHUNK_SIZE = 500;
async chunk(params: {
content: string;
documentId: string;
documentTitle: string;
categoryId?: string | null;
authorId?: string | null;
teamId?: string | null;
isPublic?: boolean;
docStatus?: number | null;
publishTime?: string | null;
}): Promise<DocumentChunk[]> {
const { content, documentId, documentTitle } = params;
if (!content?.trim()) {
this.logger.warn(`文档内容为空,跳过分块:documentId=${documentId}`);
return [];
}
// 简单切割:每500字符切一段
const textList = this.simpleSplit(content);
const chunks: DocumentChunk[] = [];
for (const text of textList) {
const trimmed = text.trim();
if (!trimmed) continue;
chunks.push({
chunkId: createHash('sha256')
.update(`${documentId}:${chunks.length}`)
.digest('hex')
.slice(0, 64),
documentId,
documentTitle,
content: trimmed,
heading: null,
chunkIndex: chunks.length,
totalChunks: 0,
categoryId: params.categoryId,
authorId: params.authorId,
teamId: params.teamId,
isPublic: params.isPublic,
docStatus: params.docStatus,
publishTime: params.publishTime,
});
}
const total = chunks.length;
chunks.forEach((c) => (c.totalChunks = total));
this.logger.debug(`简单分块完成 documentId=${documentId},总块数:${total}`);
return chunks;
}
/**
* 极简切割:每500字符一刀切,无重叠,不识别标点标题
*/
private simpleSplit(text: string): string[] {
const res: string[] = [];
let index = 0;
while (index < text.length) {
const piece = text.slice(index, index + this.CHUNK_SIZE);
res.push(piece);
index += this.CHUNK_SIZE;
}
return res;
}
}
最简单的按照标题切割
稍微升级一下,按照标题切割。
js
import { createHash } from 'crypto';
import { Injectable, Logger } from '@nestjs/common';
import { DocumentChunk } from './types/pipeline.types';
/**
* 极简按Markdown标题切割
* 只依据 # ## ###...标题做分割,不做递归字符兜底,不做重叠
* 遇到标题就切分一块;如果某章节内容超长也不会再二次切割
*/
@Injectable()
export class HeadingChunkingService {
private readonly logger = new Logger(HeadingChunkingService.name);
// 匹配markdown ATX标题 # ## ###
private readonly headingRegex = /(?=\n#{1,6}\s)/g;
async chunk(params: {
content: string;
documentId: string;
documentTitle: string;
categoryId?: string | null;
authorId?: string | null;
teamId?: string | null;
isPublic?: boolean;
docStatus?: number | null;
publishTime?: string | null;
}): Promise<DocumentChunk[]> {
const { content, documentId, documentTitle } = params;
if (!content?.trim()) {
this.logger.warn(`文档内容为空,跳过分块:documentId=${documentId}`);
return [];
}
// 按标题分割
const textList = this.splitByHeading(content);
const chunks: DocumentChunk[] = [];
for (const text of textList) {
const trimmed = text.trim();
if (!trimmed) continue;
chunks.push({
chunkId: createHash('sha256')
.update(`${documentId}:${chunks.length}`)
.digest('hex')
.slice(0, 64),
documentId,
documentTitle,
content: trimmed,
heading: null,
chunkIndex: chunks.length,
totalChunks: 0,
categoryId: params.categoryId,
authorId: params.authorId,
teamId: params.teamId,
isPublic: params.isPublic,
docStatus: params.docStatus,
publishTime: params.publishTime,
});
}
const total = chunks.length;
chunks.forEach((c) => (c.totalChunks = total));
this.logger.debug(`标题分块完成 documentId=${documentId},总块数:${total}`);
return chunks;
}
/**
* 极简分割:遇到markdown标题就切割
* 例如:#标题A\n内容\n##标题B\n内容 → 切成两块
*/
private splitByHeading(text: string): string[] {
// (?=\n#{1,6}\s) 正向零宽断言,分割时不会吃掉标题符号#
const parts = text.split(this.headingRegex);
return parts;
}
}
企业最优精简版
- 结构优先:优先按 Markdown H1‑H6 标题切分;
- 兜底策略:单块超过最大字符,用换行、句号做兜底分割;
- 父标题继承补全:被切开的子块没有标题时,自动把上级标题加到块开头;
- 简单重叠;
- 去掉 langchain 依赖,手写实现;
- 输出结构和原有
DocumentChunk完全兼容,可直接替换; - 不搞复杂 token 估算,直接用字符数。
策略简述:先用标题切大章节;章节如果超限,再按换行 / 句号细分;子块丢失标题就回填父标题上下文。
js
import { createHash } from 'crypto';
import { Injectable, Logger } from '@nestjs/common';
import { DocumentChunk } from './types/pipeline.types';
/**
* 企业RAG精简最优分块:标题优先 + 标点换行兜底 + 父标题继承补全上下文
* 1.优先按Markdown # ## ... 标题切分大章节
* 2.章节内容超过maxChunkChars,则用换行、句号兜底继续切
* 3.子块丢失标题,自动把父标题补到chunk内容头部
* 4.简单重叠避免边界语义丢失
*/
@Injectable()
export class OptimalChunkingService {
private readonly logger = new Logger(OptimalChunkingService.name);
// 可根据业务调整
private readonly maxChunkChars = 1000;
private readonly overlapChars = 120;
// 匹配markdown ATX标题
private readonly headingRegex = /^(#{1,6})\s+(.+)$/m;
// 兜底分割优先级:换行、句号、分号
private readonly splitSeps = ['\n\n', '\n', '。', ';'];
async chunk(params: {
content: string;
documentId: string;
documentTitle: string;
categoryId?: string | null;
authorId?: string | null;
teamId?: string | null;
isPublic?: boolean;
docStatus?: number | null;
publishTime?: string | null;
}): Promise<DocumentChunk[]> {
const { content, documentId, documentTitle } = params;
if (!content?.trim()) {
this.logger.warn(`文档内容为空,跳过分块:documentId=${documentId}`);
return [];
}
// 第一步:按标题切出大章节
const sections = this.splitSectionByHeading(content);
const chunks: DocumentChunk[] = [];
let currentParentHeading: string | null = null;
for (const sec of sections) {
const secTrim = sec.trim();
if (!secTrim) continue;
// 提取本章节标题
const headingMatch = secTrim.match(this.headingRegex);
if (headingMatch) {
currentParentHeading = headingMatch[2].trim();
}
// 章节超限,用标点换行兜底切小;不超限直接作为一块
const subPieces = this.splitSectionByPunctuation(secTrim);
for (let piece of subPieces) {
piece = piece.trim();
if (!piece) continue;
// 当前片段没有标题,但存在父标题 → 补全父标题上下文
if (currentParentHeading && !this.headingRegex.test(piece)) {
piece = `${currentParentHeading}\n\n${piece}`;
}
chunks.push({
chunkId: createHash('sha256')
.update(`${documentId}:${chunks.length}`)
.digest('hex')
.slice(0, 64),
documentId,
documentTitle,
content: piece,
heading: currentParentHeading,
chunkIndex: chunks.length,
totalChunks: 0,
categoryId: params.categoryId,
authorId: params.authorId,
teamId: params.teamId,
isPublic: params.isPublic,
docStatus: params.docStatus,
publishTime: params.publishTime,
});
}
}
const total = chunks.length;
chunks.forEach((c) => (c.totalChunks = total));
this.logger.debug(`最优分块完成 documentId=${documentId},总块数:${total}`);
return chunks;
}
/**
* 1.按markdown标题切分大章节,零宽断言,保留标题在块内
*/
private splitSectionByHeading(text: string): string[] {
const parts = text.split(/(?=\n#{1,6}\s)/g);
return parts;
}
/**
* 2.对单个大章节:按分隔符兜底切分,控制单块不超过maxChunkChars,带重叠
*/
private splitSectionByPunctuation(text: string): string[] {
const result: string[] = [];
let start = 0;
const step = this.maxChunkChars - this.overlapChars;
while (start < text.length) {
let end = start + this.maxChunkChars;
if (end >= text.length) {
result.push(text.slice(start));
break;
}
// 在[start,end]区间内,向后寻找最合适的分割符号,尽量不在句子中间切断
let splitPos = end;
const window = text.slice(start, end);
for (const sep of this.splitSeps) {
const idx = window.lastIndexOf(sep);
if (idx > 0) {
splitPos = start + idx + sep.length;
break;
}
}
result.push(text.slice(start, splitPos));
start += step;
}
return result;
}
}