package cn.iocoder.yudao.module.ai.service.knowledge.splitter; import cn.hutool.core.util.StrUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.transformer.splitter.TextSplitter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * 语义化文本切片器 * *

功能特点: *

* * @author runzhen */ @Slf4j public class SemanticTextSplitter extends TextSplitter { /** * 分段的最大 Token 数 */ private final int chunkSize; /** * 段落重叠大小(用于保持上下文连贯性) */ private final int chunkOverlap; /** * 段落分隔符(按优先级排序) */ private static final List PARAGRAPH_SEPARATORS = Arrays.asList( "\n\n\n", // 三个换行 "\n\n", // 双换行 "\n" // 单换行 ); /** * 句子结束标记(中英文标点) */ private static final Pattern SENTENCE_END_PATTERN = Pattern.compile( "[。!?.!?]+[\\s\"'))】\\]]*" ); /** * Token 估算器 */ private final MarkdownQaSplitter.TokenEstimator tokenEstimator; public SemanticTextSplitter(int chunkSize, int chunkOverlap) { this.chunkSize = chunkSize; this.chunkOverlap = Math.min(chunkOverlap, chunkSize / 2); // 重叠不超过一半 this.tokenEstimator = new SimpleTokenEstimator(); } public SemanticTextSplitter(int chunkSize) { this(chunkSize, 50); // 默认重叠 50 个 Token } @Override protected List splitText(String text) { if (StrUtil.isEmpty(text)) { return Collections.emptyList(); } return splitTextRecursive(text); } /** * 切分文本(递归策略) * * @param text 待切分文本 * @return 切分后的文本块列表 */ private List splitTextRecursive(String text) { List chunks = new ArrayList<>(); // 如果文本不超过限制,直接返回 int textTokens = tokenEstimator.estimate(text); if (textTokens <= chunkSize) { chunks.add(text.trim()); return chunks; } // 尝试按不同分隔符切分 List splits = null; String usedSeparator = null; for (String separator : PARAGRAPH_SEPARATORS) { if (text.contains(separator)) { splits = Arrays.asList(text.split(Pattern.quote(separator))); usedSeparator = separator; break; } } // 如果没有找到段落分隔符,按句子切分 if (splits == null || splits.size() == 1) { splits = splitBySentences(text); usedSeparator = ""; // 句子切分不需要分隔符 } // 合并小片段 chunks = mergeSplits(splits, usedSeparator); return chunks; } /** * 按句子切分 * * @param text 待切分文本 * @return 句子列表 */ private List splitBySentences(String text) { // 使用正则表达式匹配句子结束位置 List sentences = new ArrayList<>(); int lastEnd = 0; Matcher matcher = SENTENCE_END_PATTERN.matcher(text); while (matcher.find()) { String sentence = text.substring(lastEnd, matcher.end()).trim(); if (StrUtil.isNotEmpty(sentence)) { sentences.add(sentence); } lastEnd = matcher.end(); } // 添加剩余部分 if (lastEnd < text.length()) { String remaining = text.substring(lastEnd).trim(); if (StrUtil.isNotEmpty(remaining)) { sentences.add(remaining); } } return sentences.isEmpty() ? Collections.singletonList(text) : sentences; } /** * 合并切分后的小片段 * * @param splits 切分后的片段列表 * @param separator 片段间的分隔符 * @return 合并后的文本块列表 */ private List mergeSplits(List splits, String separator) { List chunks = new ArrayList<>(); List currentChunks = new ArrayList<>(); int currentLength = 0; for (String split : splits) { if (StrUtil.isEmpty(split)) { continue; } int splitTokens = tokenEstimator.estimate(split); // 如果单个片段就超过限制,进一步递归切分 if (splitTokens > chunkSize) { // 先保存当前累积的块 if (!currentChunks.isEmpty()) { String chunkText = String.join(separator, currentChunks); chunks.add(chunkText.trim()); currentChunks.clear(); currentLength = 0; } // 递归切分大片段 if (!separator.isEmpty()) { // 如果是段落分隔符,尝试按句子切分 chunks.addAll(splitTextRecursive(split)); } else { // 如果已经是句子级别,强制按字符切分 chunks.addAll(forceSplitLongText(split)); } continue; } // 计算加上分隔符的 Token 数 int separatorTokens = StrUtil.isEmpty(separator) ? 0 : tokenEstimator.estimate(separator); // 如果加上这个片段会超过限制 if (!currentChunks.isEmpty() && currentLength + splitTokens + separatorTokens > chunkSize) { // 保存当前块 String chunkText = String.join(separator, currentChunks); chunks.add(chunkText.trim()); // 处理重叠:保留最后几个片段 currentChunks = getOverlappingChunks(currentChunks, separator); currentLength = estimateTokens(currentChunks, separator); } // 添加当前片段 currentChunks.add(split); currentLength += splitTokens + separatorTokens; } // 添加最后一块 if (!currentChunks.isEmpty()) { String chunkText = String.join(separator, currentChunks); chunks.add(chunkText.trim()); } return chunks; } /** * 获取重叠的片段(用于保持上下文) * * @param chunks 当前片段列表 * @param separator 片段间的分隔符 * @return 重叠的片段列表 */ private List getOverlappingChunks(List chunks, String separator) { if (chunkOverlap == 0 || chunks.isEmpty()) { return new ArrayList<>(); } // 从后往前取片段,直到达到重叠大小 List overlapping = new ArrayList<>(); int tokens = 0; for (int i = chunks.size() - 1; i >= 0; i--) { String chunk = chunks.get(i); int chunkTokens = tokenEstimator.estimate(chunk); if (tokens + chunkTokens > chunkOverlap) { break; } // 添加到重叠列表前端 overlapping.add(0, chunk); tokens += chunkTokens + (StrUtil.isEmpty(separator) ? 0 : tokenEstimator.estimate(separator)); } return overlapping; } /** * 估算片段列表的总 Token 数 * * @param chunks 片段列表 * @param separator 片段间的分隔符 * @return 总 Token 数 */ private int estimateTokens(List chunks, String separator) { int total = 0; for (int i = 0; i < chunks.size(); i++) { total += tokenEstimator.estimate(chunks.get(i)); if (i < chunks.size() - 1 && StrUtil.isNotEmpty(separator)) { total += tokenEstimator.estimate(separator); } } return total; } /** * 强制切分长文本(当语义切分失败时) * * @param text 待切分文本 * @return 切分后的文本块列表 */ private List forceSplitLongText(String text) { List chunks = new ArrayList<>(); int charsPerChunk = (int) (chunkSize * 0.8); // 保守估计 for (int i = 0; i < text.length(); i += charsPerChunk) { int end = Math.min(i + charsPerChunk, text.length()); String chunk = text.substring(i, end); chunks.add(chunk.trim()); } log.warn("文本过长,已强制按字符切分,可能影响语义完整性"); return chunks; } /** * 简单的 Token 估算器实现 */ private static class SimpleTokenEstimator implements MarkdownQaSplitter.TokenEstimator { @Override public int estimate(String text) { if (StrUtil.isEmpty(text)) { return 0; } int chineseChars = 0; int englishWords = 0; // 简单统计中英文 for (char c : text.toCharArray()) { if (c >= 0x4E00 && c <= 0x9FA5) { chineseChars++; } } // 英文单词估算 String[] words = text.split("\\s+"); for (String word : words) { if (word.matches(".*[a-zA-Z].*")) { englishWords++; } } return chineseChars + (int) (englishWords * 1.3); } } }