文档切分(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,只允许 FixedSizeOptions 和 TextBoundaryOptions 两种实现。每种策略有独立的配置 record,避免不同策略参数混杂——固定大小策略有 chunkSize 和 overlapSize,结构感知策略有 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=-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>边界回退距离 <= 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>
* 该方法优先在换行处切分,其次在中文句末标点(。!?),再次在英文句末标点(.!?,仅当后面是空白/换行/结束才算边界)。
* 回退距离 <= 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 核心参数
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. 两种策略的对比
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 字段中VectorChunk 的 embedding 字段标记了 @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 字符)正常不会触发这个上限,这是兜底保护。