文档切分方案

作者:old wang 发布时间: 2025-06-01 阅读量:4 评论数:0

文档切分(Chunking)是 RAG 链路中最直接影响检索质量的环节——切得好,用户问什么都能命中;切得不好,向量检索再准也搜不到。

核心矛盾是:**文本块太大**,语义被稀释,检索时匹配不到精确信息;**文本块太小**,上下文丢失,LLM 拿到碎片化的内容拼不出完整答案。切分策略要在"保持语义完整"和"控制块大小"之间取平衡。

项目提供两种切分策略,通过策略模式解耦,管道配置中可按文档类型选择。入口为 ChunkerNode,它同时完成分块和向量化两步——分块结果出来后立即向量化,避免在上下文中传大量未向量化的文本。

package rag.ingestion.node;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import rag.core.chunk.ChunkEmbeddingService;
import rag.core.chunk.ChunkingOptions;
import rag.core.chunk.ChunkingStrategyFactory;
import rag.core.chunk.VectorChunk;
import rag.core.chunk.ChunkingStrategy;
import rag.framework.exception.ClientException;
import rag.ingestion.domain.context.IngestionContext;
import rag.ingestion.domain.enums.IngestionNodeType;
import rag.ingestion.domain.pipeline.NodeConfig;
import rag.ingestion.domain.result.NodeResult;
import rag.ingestion.domain.settings.ChunkerSettings;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.List;
import java.util.stream.Collectors;

/**
 * 文本分块节点,按指定策略将全文切分为可检索的 chunk,并在节点内完成向量化。
 * <p>
 * 它是 ingestion 主链路里承上启下的一环:上游提供规范化文本,下游索引节点直接消费分块结果。
 * <p>
 * 支持的分块策略:
 * <ul>
 *   <li><b>FIXED_SIZE</b>:固定大小分块,按字符数切分</li>
 *   <li><b>SENTENCE</b>:句子级别分块,保持语义完整性</li>
 *   <li><b>PARAGRAPH</b>:段落级别分块,适合长文档</li>
 *   <li><b>RECURSIVE</b>:递归分块,先按大单位再细分</li>
 * </ul>
 *
 * @author Wang
 */
@Component
@RequiredArgsConstructor
public class ChunkerNode implements IngestionNode {

    private final ObjectMapper objectMapper;
    private final ChunkingStrategyFactory chunkingStrategyFactory;
    private final ChunkEmbeddingService chunkEmbeddingService;

    @Override
    public String getNodeType() {
        return IngestionNodeType.CHUNKER.getValue();
    }

    /**
     * 执行文本分块逻辑。
     * <p>
     * 执行流程:
     * <ol>
     *   <li>从上下文获取待分块的文本(优先使用增强后的文本)</li>
     *   <li>解析分块配置,设置默认值(chunkSize=512, overlapSize=128)</li>
     *   <li>根据配置的策略创建分块器</li>
     *   <li>执行分块操作</li>
     *   <li>调用嵌入服务为所有分块生成向量</li>
     *   <li>将分块结果写回上下文</li>
     * </ol>
     *
     * @param context 摄取上下文,包含待分块的文本
     * @param config  节点配置,包含分块策略、窗口大小等参数
     * @return 节点执行结果,包含分块数量或错误信息
     */
    @Override
    public NodeResult execute(IngestionContext context, NodeConfig config) {
        String text = StringUtils.hasText(context.getEnhancedText()) ? context.getEnhancedText() : context.getRawText();
        if (!StringUtils.hasText(text)) {
            return NodeResult.fail(new ClientException("可分块文本为空"));
        }
        ChunkerSettings settings = parseSettings(config.getSettings());
        ChunkingStrategy chunker = chunkingStrategyFactory.requireStrategy(settings.getStrategy());
        if (chunker == null) {
            return NodeResult.fail(new ClientException("未找到分块策略: " + settings.getStrategy()));
        }

        ChunkingOptions chunkConfig = convertToChunkConfig(settings);
        List<VectorChunk> results = chunker.chunk(text, chunkConfig);
        List<VectorChunk> chunks = convertToVectorChunks(results);

        // 分块完成后立即补齐 embedding,保证索引节点只负责写入而不再重复计算向量。
        chunkEmbeddingService.embed(chunks, null);

        context.setChunks(chunks);
        return NodeResult.ok("已分块 " + chunks.size() + " 段");
    }

    /**
     * 转换分块配置。
     * <p>
     * 将 ChunkerSettings 转换为 ChunkingOptions,调用策略的 createDefaultOptions 方法
     * 创建适合该策略的配置对象。
     *
     * @param settings 分块器配置
     * @return 分块选项对象
     */
    private ChunkingOptions convertToChunkConfig(ChunkerSettings settings) {
        return settings.getStrategy().createDefaultOptions(
                settings.getChunkSize(), settings.getOverlapSize());
    }

    /**
     * 转换分块结果为 VectorChunk 列表。
     * <p>
     * 将分块器的输出转换为标准的 VectorChunk 对象,保留 chunkId、index、content、metadata 和 embedding。
     *
     * @param results 分块器输出的结果列表
     * @return 标准化的 VectorChunk 列表
     */
    private List<VectorChunk> convertToVectorChunks(List<VectorChunk> results) {
        return results.stream()
                .map(result -> VectorChunk.builder()
                        .chunkId(result.getChunkId())
                        .index(result.getIndex())
                        .content(result.getContent())
                        .metadata(result.getMetadata())
                        .embedding(result.getEmbedding())
                        .build())
                .collect(Collectors.toList());
    }

    /**
     * 解析分块器配置。
     * <p>
     * 将 JSON 配置转换为 ChunkerSettings 对象,并为缺失的参数设置默认值:
     * <ul>
     *   <li>chunkSize: 512(如果未配置或小于等于0)</li>
     *   <li>overlapSize: 128(如果未配置或小于0)</li>
     * </ul>
     *
     * @param node JSON 配置节点
     * @return 解析后的配置对象,包含默认值
     */
    private ChunkerSettings parseSettings(JsonNode node) {
        ChunkerSettings settings = objectMapper.convertValue(node, ChunkerSettings.class);
        // 后台未显式配置时使用默认窗口,保证通用文档也能落到可用的切块粒度。
        if (settings.getChunkSize() == null || settings.getChunkSize() <= 0) {
            settings.setChunkSize(512);
        }
        if (settings.getOverlapSize() == null || settings.getOverlapSize() < 0) {
            settings.setOverlapSize(128);
        }
        return settings;
    }
}

## 1. 架构设计

ChunkerNode(管道节点)

  │

  ├─ 读取输入文本(优先 enhancedText,其次 rawText)

  ├─ 解析 ChunkerSettings → 获取策略类型和参数

  ├─ ChunkingStrategyFactory.requireStrategy(type) → 获取策略实现

  ├─ 执行 chunking → List<VectorChunk>

  ├─ ChunkEmbeddingService.embed(chunks) → 为每个块生成向量

  └─ 写入 context.chunks → 传递给后续节点

1.1 策略模式

ChunkingStrategy定义了统一的切分契约:

public interface ChunkingStrategy {

    ChunkingMode getType();

    List<VectorChunk> chunk(String text, ChunkingOptions config);

}

ChunkingStrategyFactory 在启动时扫描所有 ChunkingStrategy 实现,按 ChunkingMode 建立映射ChunkerNode 通过工厂获取策略实例,不直接依赖具体实现。新增切分策略只需加一个实现类,不改任何现有代码。

package rag.core.chunk;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

/**
 * 文档切分策略工厂,用于管理并获取不同的文档切分实现。
 * <p>
 * 该组件通过构造器注入所有 {@link ChunkingStrategy} 类型的 Bean,在初始化时自动注册到内部映射表中。
 * 提供两种查询方式:{@link #findStrategy(ChunkingMode)}(返回 Optional)和 {@link #requireStrategy(ChunkingMode)}(不存在时抛异常)。
 */
@Component
@RequiredArgsConstructor
public class ChunkingStrategyFactory {

    private final List<ChunkingStrategy> chunkingStrategies;
    private volatile Map<ChunkingMode, ChunkingStrategy> strategies = Map.of();

    /**
     * 根据策略枚举获取对应的切分策略实现。
     * <p>
     * 该方法不会抛出异常,如果找不到匹配的策略则返回空的 Optional。
     *
     * @param type 切分策略类型枚举值
     * @return 包含匹配策略的 Optional,如果策略不存在则返回 empty
     */
    public Optional<ChunkingStrategy> findStrategy(ChunkingMode type) {
        if (type == null) return Optional.empty();
        return Optional.ofNullable(strategies.get(type));
    }

    /**
     * 获取指定类型的切分策略,如果不存在则抛出异常。
     * <p>
     * 与 {@link #findStrategy(ChunkingMode)} 不同,该方法在找不到策略时会抛出 IllegalArgumentException,
     * 适用于必须确保策略存在的场景。
     *
     * @param type 切分策略类型枚举值,不能为 null
     * @return 匹配的切分策略实现
     * @throws IllegalArgumentException 如果指定的策略类型未注册或不存在
     */
    public ChunkingStrategy requireStrategy(ChunkingMode type) {
        Objects.requireNonNull(type, "ChunkingMode type must not be null");
        return findStrategy(type)
                .orElseThrow(() -> new IllegalArgumentException("Unknown strategy: " + type));
    }

    /**
     * 初始化方法,在 Spring 容器启动后自动执行。
     * <p>
     * 该方法会遍历所有通过依赖注入的 ChunkingStrategy Bean,按照其 getType() 返回值进行注册。
     * 如果检测到重复的策略类型(即多个策略返回相同的 ChunkingMode),则抛出 IllegalStateException。
     * 最终生成的 strategies Map 是不可变的,确保线程安全。
     *
     * @throws IllegalStateException 当发现重复的 ChunkingStrategy 类型时抛出异常
     */
    @PostConstruct
    public void init() {
        Map<ChunkingMode, ChunkingStrategy> map = new EnumMap<>(ChunkingMode.class);

        chunkingStrategies.forEach(s -> {
            ChunkingStrategy old = map.put(s.getType(), s);
            if (old != null) {
                throw new IllegalStateException(
                        "Duplicate ChunkingStrategy for type: " + s.getType()
                                + " (" + old.getClass().getName() + " vs " + s.getClass().getName() + ")"
                );
            }
        });

        this.strategies = Map.copyOf(map);
    }
}

1.2 类型安全的配置

ChunkingOptions 是一个 sealed interface,只允许 FixedSizeOptionsTextBoundaryOptions 两种实现。每种策略有独立的配置 record,避免不同策略参数混杂——固定大小策略有 chunkSizeoverlapSize,结构感知策略有 targetChars maxChars minChars overlapChars

package rag.core.chunk;

import java.util.Map;

/**
 * 分块配置 sealed interface。
 * <p>
 * 该接口通过具体 record 实现类型安全的配置传递,消除魔法字符串,确保编译期类型检查。
 * 所有实现类必须是 {@link FixedSizeOptions} 或 {@link TextBoundaryOptions}。
 *
 * @see FixedSizeOptions 固定大小切分配置
 * @see TextBoundaryOptions 文本边界切分配置(结构感知等)
 */
public sealed interface ChunkingOptions permits FixedSizeOptions, TextBoundaryOptions {

    /**
     * 将配置导出为 Map,用于 API 返回和配置校验。
     * <p>
     * 该方法将类型安全的配置对象转换为通用的 Map 结构,方便序列化存储或前端展示。
     *
     * @return 包含配置项的不可变 Map,键为配置名称,值为对应的整数值
     */
    Map<String, Integer> toConfigMap();
}

FixedSizeOptions

package rag.core.chunk;

import java.util.Map;

/**
 * 固定大小切分配置。
 * <p>
 * 该配置用于 {@link ChunkingMode#FIXED_SIZE} 策略,通过指定固定的字符数和重叠大小来控制文本切分粒度。
 * 适用于对语义连贯性要求不高的场景,如代码片段、日志等。
 *
 * @param chunkSize   目标块大小(字符数),设置为 -1 时表示不切分,整个文本作为一个块
 * @param overlapSize 相邻块之间的重叠大小(字符数),用于保持上下文连贯性,避免关键信息被切断
 */
public record FixedSizeOptions(
        int chunkSize,
        int overlapSize
) implements ChunkingOptions {

    /**
     * 将配置导出为 Map,用于 API 返回和配置校验。
     *
     * @return 包含 chunkSize 和 overlapSize 的不可变 Map
     */
    @Override
    public Map<String, Integer> toConfigMap() {
        return Map.of("chunkSize", chunkSize, "overlapSize", overlapSize);
    }
}

TextBoundaryOptions

package rag.core.chunk;

import java.util.Map;

/**
 * 文本边界切分配置。
 * <p>
 * 该配置供结构感知切分等基于文本边界的切分策略共用,通过控制目标大小、最大最小限制和重叠来保持语义完整性。
 * 适用于 Markdown 文档、技术文章等结构化内容。
 *
 * @param targetChars  目标块大小(字符数),算法会尽量接近此值进行切分
 * @param overlapChars 相邻块之间的重叠大小(字符数),用于保持上下文连贯性
 * @param maxChars     块的硬上限(字符数),任何块都不会超过此大小,确保不会生成过大的 chunk
 * @param minChars     块的最小下限(字符数),小于此值的块会与后续块合并,避免产生过小的碎片
 */
public record TextBoundaryOptions(
        int targetChars,
        int overlapChars,
        int maxChars,
        int minChars
) implements ChunkingOptions {

    /**
     * 将配置导出为 Map,用于 API 返回和配置校验。
     *
     * @return 包含 targetChars、overlapChars、maxChars 和 minChars 的不可变 Map
     */
    @Override
    public Map<String, Integer> toConfigMap() {
        return Map.of("targetChars", targetChars, "overlapChars", overlapChars,
                "maxChars", maxChars, "minChars", minChars);
    }
}

ChunkingMode 枚举携带了每个策略的默认参数,通过 createDefaultOptions() 方法构建。管道配置中如果不指定参数,使用这些默认值。

package rag.core.chunk;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;

import java.util.Map;

/**
 * 文档分块策略枚举
 * 定义将文档内容切分成块的不同策略,适用于不同的文档类型和场景
 * 策略值使用小写 snake_case,如 fixed_size、structure_aware
 * <p>
 * 每个枚举常量实现两个 abstract 方法,负责构建类型安全的 ChunkingOptions
 */
@Getter
public enum ChunkingMode {

    /**
     * 固定大小切分 - 按固定字符数或 token 数切分。
     * <p>
     * 适用于对语义连贯性要求不高的场景,如代码片段、日志等。
     * 优点是实现简单、速度快;缺点是可能切断语义完整的段落。
     */
    FIXED_SIZE("fixed_size", "固定大小", true) {
        @Override
        public ChunkingOptions createOptions(Map<String, Object> config) {
            return new FixedSizeOptions(
                    toInt(config, "chunkSize", 512),
                    toInt(config, "overlapSize", 128));
        }

        @Override
        public ChunkingOptions createDefaultOptions(Integer targetSize, Integer overlapSize) {
            return new FixedSizeOptions(
                    targetSize != null ? targetSize : 512,
                    overlapSize != null ? overlapSize : 128);
        }
    },

    /**
     * 对 Markdown 友好的切分 - 保留 Markdown 结构。
     * <p>
     * 基于文本边界(标题、段落、列表)进行智能切分,尽量保持语义完整性。
     * 适用于技术文档、知识库文章等结构化内容。
     * 优点是语义连贯性好;缺点是实现复杂、速度较慢。
     */
    STRUCTURE_AWARE("structure_aware", "语义感知(Markdown 友好)", true) {
        @Override
        public ChunkingOptions createOptions(Map<String, Object> config) {
            return new TextBoundaryOptions(
                    toInt(config, "targetChars", 1400),
                    toInt(config, "overlapChars", 0),
                    toInt(config, "maxChars", 1800),
                    toInt(config, "minChars", 600));
        }

        @Override
        public ChunkingOptions createDefaultOptions(Integer targetSize, Integer overlapSize) {
            return new TextBoundaryOptions(
                    targetSize != null ? targetSize : 1400,
                    overlapSize != null ? overlapSize : 0,
                    1800,
                    600);
        }
    };

    /**
     * 枚举值的字符串表示(如 "fixed_size")。
     */
    private final String value;

    /**
     * 枚举值的中文标签(如 "固定大小")。
     */
    private final String label;

    /**
     * 是否在前端可见。
     */
    private final boolean visible;

    /**
     * 构造 ChunkingMode 枚举常量。
     *
     * @param value   枚举值的字符串表示,用于序列化和配置
     * @param label   中文标签,用于前端展示
     * @param visible 是否在前端可见
     */
    ChunkingMode(String value, String label, boolean visible) {
        this.value = value;
        this.label = label;
        this.visible = visible;
    }

    /**
     * 获取该模式的默认配置参数(用于 API 返回和配置校验)。
     * <p>
     * 该方法通过调用 {@link #createOptions(Map)} 并传入空配置来获取默认值,确保默认配置只维护一份,避免重复定义。
     *
     * @return 包含默认配置参数的 Map,键为配置项名称,值为对应的整数值
     */
    public Map<String, Integer> getDefaultConfig() {
        return createOptions(Map.of()).toConfigMap();
    }

    /**
     * 从 DB/JSON 存储的原始配置构建类型安全的 ChunkingOptions。
     * <p>
     * 该方法由各个枚举常量具体实现,负责将通用的 Map 配置转换为特定分块策略所需的配置对象。
     * 如果配置中缺少某些键,会使用策略定义的默认值进行填充。
     *
     * @param config 原始配置 Map,通常来自数据库 JSON 字段的反序列化结果
     * @return 类型安全的 ChunkingOptions 配置对象
     */
    public abstract ChunkingOptions createOptions(Map<String, Object> config);

    /**
     * 从通用参数构建 ChunkingOptions(供 ChunkerNode 等不感知具体键名的调用方使用)。
     * <p>
     * 该方法提供了一种简化的配置方式,调用方只需提供目标块大小和重叠大小两个通用参数,
     * 无需关心不同分块策略的具体配置键名。如果参数为 null,则使用该策略的默认值。
     *
     * @param targetSize  通用的目标块大小(字符数),null 时使用策略默认值
     * @param overlapSize 通用的重叠大小(字符数),null 时使用策略默认值
     * @return 根据通用参数构建的 ChunkingOptions 配置对象
     */
    public abstract ChunkingOptions createDefaultOptions(Integer targetSize, Integer overlapSize);

    // ============ 解析工具 ============

    /**
     * 安全地将配置值转换为 int 类型。
     * <p>
     * 该方法支持多种输入类型:Integer、String 等。如果值为 null 或解析失败,则返回默认值。
     *
     * @param config       配置 Map
     * @param key          配置键名
     * @param defaultValue 默认值(当值为 null 或解析失败时使用)
     * @return 转换后的整数值
     */
    static int toInt(Map<String, Object> config, String key, int defaultValue) {
        if (config == null) return defaultValue;
        Object value = config.get(key);
        if (value == null) return defaultValue;
        if (value instanceof Number num) return num.intValue();
        if (value instanceof String str && !str.isBlank()) {
            try {
                return Integer.parseInt(str.trim());
            } catch (NumberFormatException e) {
                return defaultValue;
            }
        }
        return defaultValue;
    }

    /**
     * 从字符串值反序列化为枚举实例(支持 JSON 反序列化)。
     * <p>
     * 该方法支持大小写不敏感和格式标准化(如 "fixed-size" → "fixed_size")。
     *
     * @param value 字符串值(可为 null)
     * @return 对应的枚举实例,null 输入返回 null
     * @throws IllegalArgumentException 如果值无法匹配任何枚举常量
     */
    @JsonCreator
    public static ChunkingMode fromValue(String value) {
        if (value == null) {
            return null;
        }
        String normalized = normalize(value);
        for (ChunkingMode strategy : values()) {
            if (strategy.value.equalsIgnoreCase(normalized) || strategy.name().equalsIgnoreCase(normalized)) {
                return strategy;
            }
        }
        throw new IllegalArgumentException("Unknown chunk strategy: " + value);
    }

    /**
     * 标准化字符串值:转小写并将连字符替换为下划线。
     * <p>
     * 该方法用于统一不同格式的输入,确保 "fixed-size"、"FIXED_SIZE" 等都能正确匹配到枚举常量。
     *
     * @param value 原始字符串
     * @return 标准化后的字符串(小写且使用下划线)
     */
    private static String normalize(String value) {
        String trimmed = value.trim();
        String lower = trimmed.toLowerCase();
        return lower.replace('-', '_');
    }

    /**
     * 获取枚举的序列化值(用于 JSON 序列化)。
     * <p>
     * 该方法被 @JsonValue 注解标记,Jackson 在序列化时会调用此方法获取字符串表示。
     *
     * @return 枚举的字符串表示(如 "fixed_size")
     */
    @JsonValue
    public String getValue() {
        return value;
    }
}

1.3 输入文本的选择

ChunkerNode 分块时优先使用 context.enhancedText(LLM 增强后的文本),如果增强步骤没有执行或增强结果为空,则使用 context.rawText(解析后的原始文本)。这意味着增强后的文本直接替代原文进入分块流程——上下文补充、指代消解等增强操作在分块前完成,每个分块已经是语义完整的独立片段。

2. 策略一:固定大小切分(FIXED_SIZE)

### 2.1 核心参数

参数

默认值

说明

chunkSize

512

目标块大小(字符数)

overlapSize

128

相邻块重叠大小(字符数)

配置 chunkSize=-1 时,不分块,整篇文档作为一个 chunk 输出。适用于极短文档。

2.2 参数选择依据

chunkSize = 512:中文场景下 512 字符约对应 500~800 个 token(每字符约 1~2 token)。这是大多数 Embedding 模型效果稳定的区间——太短则语义碎片化,太长则向量表示被稀释,检索精度下降。Qwen3-Embedding-8B 支持最长 8192 token 的输入,512 字符远未触及上限,作为一个经验平衡点被选为默认值。

overlapSize = 128:128 字符对应约 25% 的重叠比例。核心目的是防止关键信息恰好落在分块边界上被切断。比如一段技术文档描述"SLS 日志服务的写入配额为 1000 条/秒",如果正好在"写入"和"配额"之间切开,用户搜"SLS 写入配额"时两个块都可能命中不了。128 字符的重叠让相邻块共享边界附近的内容,大幅降低边界切断导致的漏召回。

2.3 智能边界对齐

按固定字符数直接截断,切出来的块会在句子中间断开,可读性差。固定大小切分策略在确定切分点时,不是简单地在 start + chunkSize 处一刀切,而是从目标位置向前回退,寻找更自然的断句位置。

package rag.core.chunk.strategy;

import cn.hutool.core.util.IdUtil;
import rag.core.chunk.ChunkingMode;
import rag.core.chunk.ChunkingOptions;
import rag.core.chunk.ChunkingStrategy;
import rag.core.chunk.FixedSizeOptions;
import rag.core.chunk.VectorChunk;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.ArrayList;
import java.util.List;

/**
 * 固定大小分块器。
 * <p>
 * 按固定字符数切分文本,支持重叠和边界对齐。
 * <p>
 * <b>核心特性</b>:
 * <ul>
 *   <li>按 chunkSize 切分,相邻 chunk 保留 overlapSize 重叠</li>
 *   <li>在边界符(换行/句末标点等)处向前对齐 end</li>
 *   <li>归一化:修复 URL 内“被换行拆开”的情况,但避免误吞段落换行/列表换行</li>
 *   <li>英文 '.' 不再无条件当边界,避免切烂 URL 域名</li>
 *   <li>边界回退距离 &lt;= overlap(避免出现 chunk 几乎全重复)</li>
 * </ul>
 *
 * @author Wang
 */
@Component
public class FixedSizeTextChunker implements ChunkingStrategy {

    /**
     * 获取分块器类型标识。
     *
     * @return 固定返回 {@link ChunkingMode#FIXED_SIZE}
     */
    @Override
    public ChunkingMode getType() {
        return ChunkingMode.FIXED_SIZE;
    }

    /**
     * 对文本进行固定大小分块处理。
     * <p>
     * 该方法会先对文本进行归一化处理(修复 URL 断行、中文词中间软换行等),然后按配置的 chunkSize 切分,
     * 相邻 chunk 保留 overlapSize 重叠。在边界符(换行、句末标点等)处向前对齐 end,避免切断语义完整的段落。
     * 如果 chunkSize 设置为 -1,则整个文本作为一个块返回。
     *
     * @param text   待分块的原始文本内容
     * @param config 分块配置参数,必须是 {@link FixedSizeOptions} 类型
     * @return 分块后的 VectorChunk 列表,每个 chunk 包含唯一 ID、索引和文本内容
     */
    @Override
    public List<VectorChunk> chunk(String text, ChunkingOptions config) {
        if (!StringUtils.hasText(text)) {
            return List.of();
        }

        // 1) 更保守的归一化:只修 URL 明显断行,不吞正常换行
        String normalized = normalizeText(text);

        FixedSizeOptions opts = (FixedSizeOptions) config;
        int configuredChunkSize = opts.chunkSize();
        if (configuredChunkSize == -1) {
            return List.of(VectorChunk.builder()
                    .chunkId(IdUtil.getSnowflakeNextIdStr())
                    .index(0)
                    .content(normalized)
                    .build());
        }

        int chunkSize = Math.max(1, configuredChunkSize);
        int overlap = Math.max(0, opts.overlapSize());

        if (chunkSize > 1) {
            overlap = Math.min(overlap, chunkSize - 1);
        } else {
            overlap = 0;
        }

        int len = normalized.length();
        List<VectorChunk> chunks = new ArrayList<>();

        int index = 0;
        int start = 0;
        int lastEnd = -1;

        while (start < len) {
            int targetEnd = Math.min(start + chunkSize, len);
            int end = adjustToBoundary(normalized, start, targetEnd, overlap);

            // 强制推进,避免回退过头导致重复/停滞
            if (end <= start || end <= lastEnd) {
                end = targetEnd;
            }

            String content = normalized.substring(start, end);
            if (StringUtils.hasText(content.strip())) {
                chunks.add(VectorChunk.builder()
                        .chunkId(IdUtil.getSnowflakeNextIdStr())
                        .index(index++)
                        .content(content)
                        .build());
            }

            lastEnd = end;
            if (end >= len) break;

            int nextStart = Math.max(0, end - overlap);
            if (nextStart <= start) nextStart = end;
            start = nextStart;
        }

        return chunks;
    }

    /**
     * 调整分块边界。
     * <p>
     * 该方法优先在换行处切分,其次在中文句末标点(。!?),再次在英文句末标点(.!?,仅当后面是空白/换行/结束才算边界)。
     * 回退距离 &lt;= overlap,避免 chunk 高度重复。
     *
     * @param text       待切分的文本
     * @param start      当前块的起始位置
     * @param targetEnd  目标结束位置
     * @param overlap    重叠大小
     * @return 调整后的结束位置
     */
    private int adjustToBoundary(String text, int start, int targetEnd, int overlap) {
        if (targetEnd <= start) return targetEnd;

        int maxLookback = Math.min(overlap, targetEnd - start);
        if (maxLookback <= 0) return targetEnd;

        // 1) 换行
        for (int i = 0; i <= maxLookback; i++) {
            int pos = targetEnd - i - 1;
            if (pos <= start) break;
            if (text.charAt(pos) == '\n') return pos + 1;
        }

        // 2) 中文句末标点
        for (int i = 0; i <= maxLookback; i++) {
            int pos = targetEnd - i - 1;
            if (pos <= start) break;
            char c = text.charAt(pos);
            if (c == '。' || c == '!' || c == '?') return pos + 1;
        }

        // 3) 英文句末标点:后面必须是空白/换行/结束
        for (int i = 0; i <= maxLookback; i++) {
            int pos = targetEnd - i - 1;
            if (pos <= start) break;
            char c = text.charAt(pos);
            if (c == '.' || c == '!' || c == '?') {
                int next = pos + 1;
                if (next >= text.length()) return next;
                if (Character.isWhitespace(text.charAt(next))) return next;
            }
        }

        return targetEnd;
    }

    /**
     * 归一化输入文本。
     * <p>
     * 该方法执行以下操作:
     * <ul>
     *   <li>去掉 \r</li>
     *   <li>修复“URL 被换行拆开”的情况(比如 dingtalk.\ncom、/i/nodes\n/...)</li>
     *   <li>如果换行后是“2.” 这种列表项开头,绝不合并(避免吞段落)</li>
     *   <li>URL 结束时保留原始空白(包括空行)</li>
     *   <li>修复中文词中间软换行(商\n保通 -> 商保通)</li>
     * </ul>
     *
     * @param text 原始文本
     * @return 归一化后的文本
     */
    private String normalizeText(String text) {
        if (text == null || text.isEmpty()) return text;

        String src = text.replace("\r", "");
        StringBuilder out = new StringBuilder(src.length());

        boolean inUrl = false;

        for (int i = 0; i < src.length(); i++) {
            if (!inUrl && looksLikeUrlStart(src, i)) {
                inUrl = true;
            }

            char c = src.charAt(i);

            if (inUrl) {
                if (Character.isWhitespace(c)) {
                    int j = i;
                    boolean sawNewline = false;
                    while (j < src.length() && Character.isWhitespace(src.charAt(j))) {
                        if (src.charAt(j) == '\n') sawNewline = true;
                        j++;
                    }

                    char prev = (i > 0) ? src.charAt(i - 1) : 0;
                    char next = (j < src.length()) ? src.charAt(j) : 0;

                    // 只在“很像 URL 被拆开”的情况下合并空白
                    if (sawNewline && next != 0 && shouldJoinBrokenUrl(prev, next, src, j)) {
                        i = j - 1;
                        continue;
                    }

                    // URL 结束:保留原始空白(包括空行)
                    out.append(src, i, j);
                    inUrl = false;
                    i = j - 1;
                    continue;
                }

                out.append(c);

                // 遇到明显不可能属于 URL 的字符,退出 URL 状态
                if (!isUrlChar(c) && !isCommonUrlPunct(c)) {
                    inUrl = false;
                }
                continue;
            }

            // 非 URL 状态:修复中文词中间软换行(商\n保通 -> 商保通)
            if (c == '\n') {
                char prev = (i > 0) ? src.charAt(i - 1) : 0;
                char next = (i + 1 < src.length()) ? src.charAt(i + 1) : 0;

                if (isCjkWordChar(prev) && isCjkWordChar(next)) {
                    continue;
                }

                out.append('\n');
                continue;
            }

            out.append(c);
        }

        return out.toString();
    }

    /**
     * 判断:URL 内遇到换行/空白时,是否应该把空白删掉并继续拼接 URL。
     * <p>
     * 关键:避免把 “\n2.”(列表项)吞掉。
     *
     * @param prev      换行前的字符
     * @param next      换行后的第一个非空白字符
     * @param s         原始字符串
     * @param nextIndex 下一个非空白字符的索引
     * @return 如果应该合并则返回 true
     */
    private boolean shouldJoinBrokenUrl(char prev, char next, String s, int nextIndex) {
        // 如果下一行像 “2.” “10.” 这种列表项开头 -> 绝不合并
        if (isListItemStart(s, nextIndex)) {
            return false;
        }

        // 典型的 URL 断行场景:在这些字符后面换行,后续大概率还是 URL
        if (prev == '.' && Character.isLetter(next)) return true;                 // dingtalk.\ncom
        if (prev == '/' || prev == '?' || prev == '&' || prev == '='
                || prev == '#' || prev == '%' || prev == '-' || prev == '_'
                || prev == ':') return true;                                       // /i/nodes\n/...  ?\nutm=...

        // 或者下一段本身以 URL 结构符号开头
        if (next == '/' || next == '?' || next == '&' || next == '=' || next == '#') return true;

        // 其他情况更保守:不合并,保留换行
        return false;
    }

    /**
     * 检查指定位置是否是列表项开头(如 “2.”、“10.”)。
     *
     * @param s 原始字符串
     * @param i 待检查的位置
     * @return 如果是列表项开头则返回 true
     */
    private boolean isListItemStart(String s, int i) {
        // 跳过可能存在的空格/制表符(一般是新行后的缩进)
        int p = i;
        while (p < s.length() && (s.charAt(p) == ' ' || s.charAt(p) == '\t')) p++;

        int start = p;
        while (p < s.length() && Character.isDigit(s.charAt(p))) p++;
        if (p == start) return false;

        // 数字后紧跟 '.' 或 ')' / ')' 也常见
        if (p < s.length() && (s.charAt(p) == '.' || s.charAt(p) == ')' || s.charAt(p) == ')')) {
            return true;
        }
        return false;
    }

    /**
     * 检查指定位置是否看起来像 URL 开头。
     *
     * @param s 原始字符串
     * @param i 待检查的位置
     * @return 如果以 http:// 或 https:// 开头则返回 true
     */
    private boolean looksLikeUrlStart(String s, int i) {
        if (i < 0 || i >= s.length()) return false;
        return s.startsWith("http://", i) || s.startsWith("https://", i);
    }

    /**
     * 检查字符是否是 URL 合法字符。
     *
     * @param c 待检查的字符
     * @return 如果是 URL 合法字符则返回 true
     */
    private boolean isUrlChar(char c) {
        if (c >= 'a' && c <= 'z') return true;
        if (c >= 'A' && c <= 'Z') return true;
        if (c >= '0' && c <= '9') return true;

        return c == '-' || c == '.' || c == '_' || c == '~'
                || c == ':' || c == '/' || c == '?' || c == '#'
                || c == '[' || c == ']' || c == '@'
                || c == '!' || c == '$' || c == '&' || c == '\''
                || c == '(' || c == ')' || c == '*' || c == '+'
                || c == ',' || c == ';' || c == '=' || c == '%';
    }

    /**
     * 检查字符是否是常见的 URL 标点符号。
     *
     * @param c 待检查的字符
     * @return 如果是常见 URL 标点则返回 true
     */
    private boolean isCommonUrlPunct(char c) {
        return c == '.' || c == '/' || c == '?' || c == '&' || c == '=' || c == '-' || c == '_' || c == '%';
    }

    /**
     * 检查字符是否是中文词字符(非空白、非标点的 CJK 字符)。
     *
     * @param c 待检查的字符
     * @return 如果是中文词字符则返回 true
     */
    private boolean isCjkWordChar(char c) {
        if (c == 0) return false;
        if (Character.isWhitespace(c)) return false;
        if (!isCjkOrFullWidthLetterOrDigit(c)) return false;
        return !isCjkPunctuation(c);
    }

    /**
     * 检查字符是否是 CJK 或全角字母/数字。
     *
     * @param c 待检查的字符
     * @return 如果是 CJK 或全角字母/数字则返回 true
     */
    private boolean isCjkOrFullWidthLetterOrDigit(char c) {
        if (c == 0) return false;
        Character.UnicodeBlock block = Character.UnicodeBlock.of(c);
        return block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
                || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
                || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
                || block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS
                || block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS;
    }

    /**
     * 检查字符是否是 CJK 标点符号。
     *
     * @param c 待检查的字符
     * @return 如果是 CJK 标点则返回 true
     */
    private boolean isCjkPunctuation(char c) {
        Character.UnicodeBlock block = Character.UnicodeBlock.of(c);
        return block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
                || block == Character.UnicodeBlock.GENERAL_PUNCTUATION
                || c == '。' || c == ',' || c == '、' || c == ';' || c == ':'
                || c == '!' || c == '?' || c == '(' || c == ')' || c == '【' || c == '】'
                || c == '《' || c == '》' || c == '“' || c == '”' || c == '‘' || c == '’';
    }
}

回退优先级FixedSizeTextChunker.adjustToBoundary()

1. 换行符:最高优先级。换行通常是段落边界,是语义的自然断点

2. 中文句末标点(。!?):句号、感叹号、问号是中文句子边界的标志

3. 英文句末标点(. ! ?):仅在后面跟着空白字符或文本结束时才视为断句位置。这个条件判断很关键——没有它,URL 中的点号dingtalk.com)会被误判为句子结束,切出一个"dingtalk."和".com"的荒谬结果

回退距离不超过 overlap(128 字符)。如果 128 字符范围内找不到合适的边界,不做对齐,直接在目标位置截断。这个限制保证了不会为了对齐导致当前块和上一块几乎完全重叠。

2.4 URL 断行修复

PDF 转文本时的一个典型问题:长 URL 被换行拆开dingtalk.\ncom/i/nodes\n/123456)。如果不修复,这些换行会成为误切分点,而且 URL 被拆成两段后检索命中率直接降为零。

固定大小切分策略在分块前先做文本归一化FixedSizeTextChunker.normalizeText()):

识别 URL:扫描到 http://https:// 开头时,进入 URL 识别状态。URL 内的后续字符(字母、数字、常见 URL 符号)被标记为 URL 的有机组成部分。

判断是否合并:遇到换行时,检查换行前后的字符是否都像 URL 的有机组成部分。比如:

- dingtalk.\ncom → 前一个字符是 .,后一个字符是 c(字母)→ 合并

- /i/nodes\n/123456 → 前一个字符是 /,后一个字符是 / → 合并

- ?utm=\nsource → 前一个字符是 =,后一个字符是 s → 合并

防御规则:换行后如果是列表项开头("2."、"10." 这类数字加点号或括号的格式),**绝不合并**。因为 \n2. 是一个段落的结束和下一个列表项的开始,不是 URL 的延续。这个防御规则很关键——没有它,正常的文档结构会被严重破坏。

中文软换行修复:同时修复中文词中间的软换行("商\n保通" → "商保通")。判断条件是换行前后都是 CJK 字符且都不是标点符号,说明这是排版导致的断行而非语义断点。

3. 策略二:结构感知切分(STRUCTURE_AWARE)

3.1 核心参数

参数

默认值

说明

targetChars

1400

目标块大小(字符数)

maxChars

1800

硬上限,超过此值必须切分

minChars

600

软下限,不足时宁可超限也要合并

overlapChars

0

重叠大小,结构感知下默认为 0

3.2 适用场景

结构感知切分专门为 Markdown 文档优化。如果说固定大小切分是"不管文档结构,统一按长度锯",结构感知切分就是"先理解文档结构,然后在结构边界上拆"。

为什么不同的文档需要不同的切分策略?

Markdown 有明确的层级标记——标题#、代码块(```)、图片链接![]()/[]()、段落(空行分隔)。这些标记本身就是语义边界,按固定字符数切分反而会破坏它们。一段代码块切成两半,LLM 拿着半截代码不可能给出正确的技术回答。

3.3 块扫描

package rag.core.chunk.strategy;

import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import rag.core.chunk.ChunkingMode;
import rag.core.chunk.ChunkingOptions;
import rag.core.chunk.ChunkingStrategy;
import rag.core.chunk.TextBoundaryOptions;
import rag.core.chunk.VectorChunk;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;

/**
 * 结构感知分块器(Markdown 友好版)。
 * <p>
 * 基于 Markdown 文档结构进行智能切分,保持语义完整性。
 * <p>
 * <b>核心特性</b>:
 * <ul>
 *   <li>绝不改写文本,只在“块”边界切分</li>
 *   <li>块类型:Heading、Paragraph(空行分段)、CodeFence(```...```)、Atomic(整行 ![]()/[]())</li>
 *   <li>通过 min/target/max 预算控制 chunk 大小</li>
 *   <li>支持可选的 overlap</li>
 * </ul>
 *
 * @author Wang
 */
@Component
public class StructureAwareTextChunker implements ChunkingStrategy {

    private static final Pattern HEADING = Pattern.compile("^#{1,6}\\s+.*$");
    private static final Pattern CODE_FENCE = Pattern.compile("^```.*$");
    private static final Pattern ATOMIC_IMAGE = Pattern.compile("^!\\[[^]]*]\\([^)]+\\)(?:\\s*\"[^\"]*\")?\\s*$");
    private static final Pattern ATOMIC_LINK = Pattern.compile("^\\[[^]]+]\\([^)]+\\)\\s*$");

    /**
     * 获取分块器类型标识。
     *
     * @return 固定返回 {@link ChunkingMode#STRUCTURE_AWARE}
     */
    @Override
    public ChunkingMode getType() {
        return ChunkingMode.STRUCTURE_AWARE;
    }

    /**
     * 对 Markdown 文档进行结构感知分块处理。
     * <p>
     * 该方法基于 Markdown 文档结构(标题、段落、代码块、图片/链接等)进行智能切分,保持语义完整性。
     * 核心流程:
     * <ol>
     *   <li>统一行尾符(\r\n → \n)</li>
     *   <li>扫描文本生成块列表(Block),记录原文的 start/end 下标</li>
     *   <li>依据 min/target/max 预算将块打包成 chunk(只在块边界切分)</li>
     *   <li>可选地加入重叠:复制上一 chunk 的尾部全文子串到下一 chunk 开头</li>
     * </ol>
     *
     * @param text   待分块的 Markdown 文本内容
     * @param config 分块配置参数,必须是 {@link TextBoundaryOptions} 类型
     * @return 分块后的 VectorChunk 列表,每个 chunk 包含唯一 ID、索引和文本内容
     */
    @Override
    public List<VectorChunk> chunk(String text, ChunkingOptions config) {
        if (StrUtil.isBlank(text)) return List.of();

        // 统一行尾:Windows \r\n → \n,老 Mac \r → \n,避免 \r 残留导致空行/标题识别失败
        text = text.replace("\r\n", "\n").replace("\r", "\n");

        TextBoundaryOptions opts = (TextBoundaryOptions) config;
        int effectiveTarget = opts.targetChars();
        int effectiveMax = opts.maxChars();
        int effectiveMin = opts.minChars();
        int effectiveOverlap = opts.overlapChars();

        // 1) 扫描成“块”(记录原文的 start/end 下标,确保输出 substring 完全等于原文)
        List<Block> blocks = segmentToBlocks(text);

        if (blocks.isEmpty()) {
            VectorChunk chunk = VectorChunk.builder()
                    .content(text)
                    .index(0)
                    .chunkId(IdUtil.getSnowflakeNextIdStr())
                    .build();
            return List.of(chunk); // 极端兜底:整体作为一个块
        }

        // 2) 依据 min/target/max 打包成 chunk(只在块边界切分)
        List<int[]> ranges = packBlocksToChunks(blocks, text.length(), effectiveMin, effectiveTarget, effectiveMax);

        // 3)(可选)加入重叠:为保持“只在块边界切分”,这里不在中间加重叠,若开启 overlap,仅复制“上一 chunk 的尾部全文子串”到下一 chunk 的开头
        List<VectorChunk> out = materialize(text, ranges, effectiveOverlap);

        // 编号从 0 递增
        for (int i = 0; i < out.size(); i++) {
            VectorChunk chunk = VectorChunk.builder()
                    .content(out.get(i).getContent())
                    .index(i)
                    .chunkId(IdUtil.getSnowflakeNextIdStr())
                    .build();
            out.set(i, chunk);
        }
        return out;
    }

    // ----------- 块模型 -----------

    /**
     * 文本块模型,记录原文中的位置和类型。
     */
    @Getter
    @ToString
    @AllArgsConstructor
    private static class Block {

        /**
         * 块的类型枚举。
         */
        enum Kind {
            /** 标题块 */
            HEADING,
            /** 代码块 */
            CODE,
            /** 原子块(图片/链接) */
            ATOMIC,
            /** 段落块 */
            PARA
        }

        final Block.Kind kind;
        final int start;   // 在原文中的起始(含)
        final int end;     // 在原文中的结束(不含)
    }

    // ----------- 1) 线性扫描生成块 -----------

    /**
     * 将文本线性扫描为块列表。
     * <p>
     * 该方法逐行分析文本,识别标题、代码围栏、原子行(图片/链接)和段落,并记录每个块在原文中的起始和结束位置。
     *
     * @param text 待扫描的原始文本
     * @return 按顺序排列的块列表
     */
    private List<Block> segmentToBlocks(String text) {
        List<Block> blocks = new ArrayList<>();
        int n = text.length();
        int pos = 0;

        boolean inFence = false;
        int fenceStart = -1;

        boolean inPara = false;
        int paraStart = -1;

        while (pos < n) {
            int lineEnd = indexOfNl(text, pos);
            // [pos, lineEnd) 不含换行字符;lineEndNl = 包含换行(若有)
            int lineEndNl = lineEnd < n && text.charAt(lineEnd) == '\n' ? lineEnd + 1 : lineEnd;
            String line = text.substring(pos, lineEnd);

            String trimmed = trimRightKeepLeft(line); // 不改左侧空白,保留原貌;右侧空白不影响判断

            if (!inFence && CODE_FENCE.matcher(trimmed).matches()) {
                // 先把正在积累的段落收尾
                if (inPara) {
                    blocks.add(new Block(Block.Kind.PARA, paraStart, pos));
                    inPara = false;
                }
                // 进入代码围栏
                inFence = true;
                fenceStart = pos;
                pos = lineEndNl;
                continue;
            }

            if (inFence) {
                // 直到遇到 fence 结束行
                if (CODE_FENCE.matcher(trimmed).matches()) {
                    // 包含结束 fence 行
                    blocks.add(new Block(Block.Kind.CODE, fenceStart, lineEndNl));
                    inFence = false;
                }
                pos = lineEndNl;
                continue;
            }

            // 空行 => 段落边界
            if (trimmed.isEmpty()) {
                if (inPara) {
                    blocks.add(new Block(Block.Kind.PARA, paraStart, pos));
                    inPara = false;
                }
                // 空行本身并入前一块或下一块?——保持原貌:把空行并入前一块(若无前一块,则作为 0 长度过渡)
                pos = lineEndNl;
                continue;
            }

            // 标题/原子行(图片/链接)都作为独立块
            if (HEADING.matcher(trimmed).matches()) {
                if (inPara) {
                    blocks.add(new Block(Block.Kind.PARA, paraStart, pos));
                    inPara = false;
                }
                blocks.add(new Block(Block.Kind.HEADING, pos, lineEndNl));
                pos = lineEndNl;
                continue;
            }
            if (ATOMIC_IMAGE.matcher(trimmed).matches() || ATOMIC_LINK.matcher(trimmed).matches()) {
                if (inPara) {
                    blocks.add(new Block(Block.Kind.PARA, paraStart, pos));
                    inPara = false;
                }
                blocks.add(new Block(Block.Kind.ATOMIC, pos, lineEndNl));
                pos = lineEndNl;
                continue;
            }

            // 其他:并入当前段落
            if (!inPara) {
                inPara = true;
                paraStart = pos;
            }
            pos = lineEndNl;
        }

        // 收尾
        if (inFence) {
            // 未闭合 fence:将剩余部分作为 CODE(保持原样)
            blocks.add(new Block(Block.Kind.CODE, fenceStart, n));
        } else if (inPara) {
            blocks.add(new Block(Block.Kind.PARA, paraStart, n));
        }
        return coalesceTrailingBlanks(blocks, text);
    }

    /**
     * 合并块尾部的若干空行到块内部,避免单独产生空白块。
     * <p>
     * 该方法保持原文不变,只是将空行的归属调整到前一个块中。
     *
     * @param blocks 原始块列表
     * @param text   原始文本(用于检查空白)
     * @return 合并后的块列表
     */
    private List<Block> coalesceTrailingBlanks(List<Block> blocks, String text) {
        if (blocks.isEmpty()) return blocks;
        List<Block> out = new ArrayList<>();
        Block prev = blocks.get(0);
        for (int i = 1; i < blocks.size(); i++) {
            Block cur = blocks.get(i);
            if (isAllBlank(text, prev.end, cur.start)) {
                // 把中间空白并入 prev,但别丢掉 cur
                prev = new Block(prev.kind, prev.start, cur.start);
            }
            // 无论是否并入空白,prev 都该进结果,然后向前推进
            out.add(prev);
            prev = cur;
        }
        out.add(prev);
        return out;
    }

    // ----------- 2) 打包成 chunk(仅在块边界切) -----------

    /**
     * 将块列表打包成 chunk 范围。
     * <p>
     * 该方法根据 min/target/max 预算控制每个 chunk 的大小,确保不会生成过小或过大的块。
     * 如果最后一个 chunk 明显过小,会尝试与前一个合并。
     *
     * @param blocks  块列表
     * @param textLen 文本总长度
     * @param min     最小块大小
     * @param target  目标块大小
     * @param max     最大块大小
     * @return chunk 范围列表,每个元素为 [start, end] 数组
     */
    private List<int[]> packBlocksToChunks(List<Block> blocks, int textLen, int min, int target, int max) {
        List<int[]> ranges = new ArrayList<>();
        int i = 0;
        while (i < blocks.size()) {
            int chunkStart = blocks.get(i).start;
            int chunkEnd = blocks.get(i).end; // 不含
            int size = chunkEnd - chunkStart;

            int j = i + 1;
            while (j < blocks.size()) {
                Block b = blocks.get(j);
                int afterAdd = (b.end - chunkStart); // 等同于 size + nextSize + 中间空白(已包含)

                if (afterAdd <= max) {
                    // 还能加
                    chunkEnd = b.end;
                    size = afterAdd;
                    j++;
                } else {
                    // 超过 max:若当前 size < min,则“忍一次超限”,把这个块也吸进去(保证不要太小)
                    if (size < min) {
                        chunkEnd = b.end;
                        size = afterAdd;
                        j++;
                    }
                    break;
                }
            }

            ranges.add(new int[]{chunkStart, chunkEnd});
            i = j;
        }

        // 若最后一个 chunk 明显过小,尝试与前一个合并(仍不跨越 max 过多)
        if (ranges.size() >= 2) {
            int[] last = ranges.get(ranges.size() - 1);
            if (last[1] - last[0] < Math.min(min, target / 2)) {
                int[] prev = ranges.get(ranges.size() - 2);
                if (last[1] - prev[0] <= max * 2) { // 放宽一下,尽量合并到可接受大小
                    prev[1] = last[1];
                    ranges.remove(ranges.size() - 1);
                }
            }
        }
        return ranges;
    }

    // ----------- 3) 物化为 Chunk,必要时追加 overlap(复制原文尾部) -----------

    /**
     * 将 chunk 范围物化为 VectorChunk 对象,必要时追加 overlap。
     * <p>
     * 如果启用了 overlap,该方法会复制上一 chunk 的尾部全文子串到当前 chunk 开头,以保持上下文连贯性。
     *
     * @param text    原始文本
     * @param ranges  chunk 范围列表
     * @param overlap 重叠大小(字符数)
     * @return 物化后的 VectorChunk 列表
     */
    private List<VectorChunk> materialize(String text, List<int[]> ranges, int overlap) {
        if (ranges.isEmpty()) return List.of();
        List<VectorChunk> out = new ArrayList<>();
        String prevTail = null;

        for (int k = 0; k < ranges.size(); k++) {
            int s = ranges.get(k)[0];
            int e = ranges.get(k)[1];
            String body = text.substring(s, e);
            if (overlap > 0 && prevTail != null && !prevTail.isEmpty()) {
                body = prevTail + body;
            }

            VectorChunk chunk = VectorChunk.builder()
                    .content(body)
                    .index(k)
                    .chunkId(IdUtil.getSnowflakeNextIdStr())
                    .build();
            out.add(chunk);

            // 计算下一块的 overlap 尾部(完全来自本 chunk 原文结尾)
            if (overlap > 0) {
                prevTail = tailByChars(text.substring(s, e), overlap);
            }
        }
        return out;
    }

    // ----------- 小工具 -----------

    /**
     * 查找从指定位置开始的下一个换行符位置。
     *
     * @param s    待搜索的字符串
     * @param from 起始位置
     * @return 换行符的位置,如果未找到则返回字符串长度
     */
    private int indexOfNl(String s, int from) {
        int p = s.indexOf('\n', from);
        return p < 0 ? s.length() : p;
    }

    /**
     * 去除字符串右侧空白,但保留左侧空白。
     * <p>
     * 该方法用于判断行类型时忽略尾部空格,同时保持原文格式不变。
     *
     * @param s 原始字符串
     * @return 去除右侧空白后的子串
     */
    private String trimRightKeepLeft(String s) {
        int r = s.length();
        while (r > 0 && Character.isWhitespace(s.charAt(r - 1)) && s.charAt(r - 1) != '\n' && s.charAt(r - 1) != '\r') {
            r--;
        }
        return s.substring(0, r);
    }

    /**
     * 检查指定范围内的字符是否全部为空白。
     *
     * @param s    待检查的字符串
     * @param from 起始位置(含)
     * @param to   结束位置(不含)
     * @return 如果范围内所有字符都是空白则返回 true
     */
    private boolean isAllBlank(String s, int from, int to) {
        for (int i = from; i < to; i++) {
            char c = s.charAt(i);
            if (!(c == ' ' || c == '\t' || c == '\r' || c == '\n')) return false;
        }
        return true;
    }

    /**
     * 获取字符串的尾部 n 个字符。
     *
     * @param s 原始字符串
     * @param n 要获取的字符数
     * @return 尾部子串,如果字符串长度不足 n 则返回整个字符串
     */
    private String tailByChars(String s, int n) {
        if (n <= 0) return "";
        int len = s.length();
        return len <= n ? s : s.substring(len - n);
    }
}

StructureAwareTextChunker 先逐行扫描文档,识别出四种块类型(Block):

标题块(HEADING):以 # 开头的行。标题是独立的语义单元,不应该和内文混在一起。

代码围栏块(CODE):以 ``` 配对包裹的代码区。代码块整体作为一个不可分割的块——内部不管多少行,都不会被截断。

原子块(ATOMIC):整行都是图片或链接语法的行![]()[]())。这些内容通常是文档中引用外部资源的标记,和周围文本的语义关联较弱,独立成块更合适。

段落块(PARA):以空行为边界分隔的普通文本段落。连续的非空行组成一个段落块。

扫描过程中处理了围栏未闭合的容错:如果代码围栏从某行开始但始终没有遇到闭合标记,剩余内容统一归入一个代码块,不会丢失。

3.4 装箱算法

扫描完块之后,不是简单把每个块当做一个 chunk 输出——那样会产生大量碎片(一个标题行可能只有十几个字,一个原子行可能只有一行图片链接)。

而是把块序列当做"货物",按 min/target/max 的预算约束做装箱StructureAwareTextChunker.packBlocksToChunks()):

1. 从第一个块开始,依次往后加,直到累计大小超过 targetChars 或下一个块加上后会超过 maxChars

2. 如果当前箱(chunk)的内容还不足 minChars,即使下一个块加上后会超过 maxChars,也强行纳入——宁可超限也不要产出碎片

3. 最后一个箱如果内容明显小于 minChars(不到 target 的一半),和前一个箱合并——同样宁可大小不匀也不要留下一个孤立的碎片

这套算法的核心思想是:**chunk 的大小可以有弹性,但语义完整性不能妥协**。每个 chunk 包含的若干块在原文中是连续的,保留了块之间的自然关联。

3.5 重叠实现

结构感知模式下 overlapChars 默认为 0,因为块边界本身就是语义边界,不需要强行制造重叠来防止截断。

如果配置了重叠,实现方式是:每个 chunk 在输出时,将前一个 chunk 的尾部(按字符数截取 overlapChars 个字符)追加到当前 chunk 的开头。这样做的好处是不破坏块边界——重叠部分是从原文完整复制来的尾部文字,而不是在块中间重新切一刀。

## 4. 两种策略的对比

维度

固定大小切分

结构感知切分

适用文档

PDF、纯文本、无结构文档

Markdown、有层级结构的文档

切分依据

固定字符数 + 边界对齐

文档结构标记(标题/代码/段落)

语义完整性

可能截断句子(有对齐缓解)

保证标题/代码块/段落不被拆散

块大小控制

精确(chunkSize ± overlap)

弹性(min~max 区间)

块数量

文档长度 / 512

取决于文档结构,通常更少

检索特征

重叠保证了边界信息的覆盖

语义单元完整,检索精度更高

成本

Embedding API 调用次数多

调用次数少,块更大、token 消耗更多

5. 向量化时机

切分完成后ChunkerNode 立即调用 ChunkEmbeddingService.embed(chunks, null) 为每个块生成向量。合在同一个节点的原因:分块结果是向量化的唯一输入。如果拆成两个节点,需要在 IngestionContext 中传递未向量化的文本列表,增加内存占用和序列化开销。

package rag.core.chunk;

import rag.framework.exception.ClientException;
import rag.infra.embedding.EmbeddingService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.List;

/**
 * 文本分块向量化服务。
 * <p>
 * 接在 chunking 之后、向量库写入之前,负责把 {@link VectorChunk} 的文本内容批量送入 embedding 服务,
 * 再把结果原地回填到 chunk 对象,供后续索引流程复用。
 */
@Service
@RequiredArgsConstructor
public class ChunkEmbeddingService {

    private final EmbeddingService embeddingService;

    /**
     * 为分块列表计算 embedding。
     * <p>
     * 该方法会原地修改传入的 chunks 列表,将计算得到的向量嵌入填充到每个 chunk 的 embedding 字段。
     * 如果所有 chunk 已经包含有效的 embedding,则跳过计算以避免重复消耗配额。
     *
     * @param chunks         已切分的文本块列表,方法执行后会原地写入 embedding 结果
     * @param embeddingModel 使用的 embedding 模型 ID;如果为空或 null,则使用系统默认模型进行路由
     * @throws ClientException 当 embedding 服务返回的向量数量与输入 chunk 数量不匹配时抛出异常
     */
    public void embed(List<VectorChunk> chunks, String embeddingModel) {
        if (chunks == null || chunks.isEmpty()) {
            return;
        }
        // 已经带向量的 chunk 不重复调用模型,避免编辑后重复入库时再次消耗 embedding 配额。
        if (chunks.stream().allMatch(c -> c.getEmbedding() != null && c.getEmbedding().length > 0)) {
            return;
        }
        List<String> texts = chunks.stream()
                .map(c -> c.getContent() == null ? "" : c.getContent())
                .toList();
        List<List<Float>> vectors = StringUtils.hasText(embeddingModel)
                ? embeddingService.embedBatch(texts, embeddingModel)
                : embeddingService.embedBatch(texts);
        applyEmbeddings(chunks, vectors);
    }

    /**
     * 按顺序把 embedding 结果回填到 chunk。
     * <p>
     * 这里要求返回向量数量与输入 chunk 数量完全一致,否则上游无法保证文本和向量一一对应。
     */
    private void applyEmbeddings(List<VectorChunk> chunks, List<List<Float>> vectors) {
        if (vectors == null || vectors.size() != chunks.size()) {
            throw new ClientException("Embedding result size mismatch");
        }
        for (int i = 0; i < chunks.size(); i++) {
            List<Float> row = vectors.get(i);
            if (row == null) {
                throw new ClientException("Embedding result missing, index: " + i);
            }
            float[] vec = new float[row.size()];
            for (int j = 0; j < row.size(); j++) {
                vec[j] = row.get(j);
            }
            chunks.get(i).setEmbedding(vec);
        }
    }
}

向量化走模型路由层——优先 SiliconFlow 的千问 Embedding 8B API(1536 维),失败降级到本地 Ollama 或 AIHubMix。支持批量调用(batchSize=32),一次 API 请求处理最多 32 个 chunk,大幅减少 HTTP 调用次数。

生成的向量以 float[] 形式存储在 VectorChunk.embedding 字段中VectorChunkembedding 字段标记了 @JsonIgnore,JSON 序列化时自动跳过——避免在节点日志的 outputJson 中嵌入几千个浮点数导致输出截断。

6. 参数调优指南

6.1 固定大小切分

增大 chunkSize:适用于长文档、偏概述性的内容。更大的块携带更多上下文,LLM 能给出更全面的回答。代价是向量表示被稀释,精确事实检索的命中率下降。同时 Embedding API 的成本和延时都会增加。

减小 chunkSize:适用于 FAQ、技术规范等需要精确匹配的场景。小块对应更精确的向量匹配。代价是块数量增多,Embedding API 调用次数和存储成本上升。

增大 overlapSize:适用于分布密集的知识点(同一个段落包含多个相关事实)。更大的重叠保证边界信息不丢失。但 overlap 过大会导致存储冗余——两个相邻块有 50% 的内容重复,检索返回的结果看起来像"翻来覆去就那几句"。

6.2 结构感知切分

增大 targetChars:适用于长章节的技术手册。一个章节整体作为一个 chunk,保持了完整的逻辑链。但太大会超过 Embedding 模型的最佳输入长度。

减小 minChars:容忍更小的碎片。不推荐——碎片化的 chunk 携带的上下文太少,检索结果对 LLM 帮助有限。

配置 overlapChars:如果文档中相邻章节有强关联(比如"详见第 X 章"),设置 overlap 可以让每个 chunk 携带上一节的尾部作为上下文提示。

7. 边界情况

空文本:分块器直接返回空列表。

文本极短(不足一个 chunkSize 或不足 minChars):整体作为一个 chunk 输出,不做切分。ChunkerNode 中如果 chunkSize=-1 也是同样的效果。

代码块嵌套:Markdown 中代码块内部可能包含 ``` 标记(比如示例代码展示 Markdown 语法本身)。当前实现按首次出现 ``` 判断围栏起止,嵌套场景可能误判。这是已知局限,靠文档编写规范来规避——在代码块内展示 Markdown 语法时增加嵌套缩进。

超大段落:段落块超过 maxChars 时,结构感知策略不会强制从中间切分——因为"块"本身没有内部切分点。解决办法是上游文档编辑时将过长的段落拆成多个段落,或者切换到固定大小切分策略处理这类文档。

Embedding API 超时:向量化阶段走模型路由的故障转移,API 超时自动切备选模型。所有候选失败则抛异常,管道终止并触发事务回滚。

内容过长导致向量表字段溢出:IndexerNode 写入前自动截断超过 65535 字符的内容。文档层面的切分控制(512~1800 字符)正常不会触发这个上限,这是兜底保护。

评论