ai agent --- 切割文件 RecursiveCharacterTextSplitter

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;
  }
}

企业最优精简版

  1. 结构优先:优先按 Markdown H1‑H6 标题切分;
  2. 兜底策略:单块超过最大字符,用换行、句号做兜底分割;
  3. 父标题继承补全:被切开的子块没有标题时,自动把上级标题加到块开头;
  4. 简单重叠;
  5. 去掉 langchain 依赖,手写实现;
  6. 输出结构和原有DocumentChunk完全兼容,可直接替换;
  7. 不搞复杂 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;
  }
}
相关推荐
松就是我902981 小时前
可编辑 PPT:调研、设计与实施方案
前端
前端snow1 小时前
ai agent --- Elasticsearch 混合检索
前端
java1234_小锋1 小时前
【技术专题】HTML5 网页技术 - HTML5 文本与排版标签
前端·html·html5
Wokoo71 小时前
Selenium:元素定位、动态适配与性能优化(二)
前端·selenium·自动化
用户2788607285671 小时前
把 restore 当撤销会发生什么?长任务助手的会话与业务状态
前端
℘团子এ1 小时前
vue3中自定义指令的使用
前端·javascript·vue.js
用户2788607285671 小时前
别把 AI 查询结果只塞进聊天框:SessionView 与业务页面接线
前端
程序员鱼皮1 小时前
Personal Agent 是什么?为什么大厂都抢着做?连 DeepSeek Bot 都来了。。
前端·后端·ai编程
用户4402818278071 小时前
一个人 + TRAE,从 0 到 1 交付一个政务志愿服务平台的全链路实战
前端