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; /** * 语义化文本切片器 * 优先在段落边界处切分,其次在句子边界处切分,保持语义完整性 */ @Slf4j public class SemanticTextSplitter extends TextSplitter { 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\"'))】\\]]*"); 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); } @Override protected List splitText(String text) { if (StrUtil.isEmpty(text)) return Collections.emptyList(); return splitTextRecursive(text); } private List splitTextRecursive(String text) { int textTokens = tokenEstimator.estimate(text); if (textTokens <= chunkSize) return Collections.singletonList(text.trim()); 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 = ""; } return mergeSplits(splits, usedSeparator); } 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; } 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()) { chunks.add(String.join(separator, currentChunks).trim()); currentChunks.clear(); currentLength = 0; } if (!separator.isEmpty()) { chunks.addAll(splitTextRecursive(split)); } else { chunks.addAll(forceSplitLongText(split)); } continue; } int separatorTokens = StrUtil.isEmpty(separator) ? 0 : tokenEstimator.estimate(separator); if (!currentChunks.isEmpty() && currentLength + splitTokens + separatorTokens > chunkSize) { chunks.add(String.join(separator, currentChunks).trim()); currentChunks = getOverlappingChunks(currentChunks, separator); currentLength = estimateTokens(currentChunks, separator); } currentChunks.add(split); currentLength += splitTokens + separatorTokens; } if (!currentChunks.isEmpty()) chunks.add(String.join(separator, currentChunks).trim()); return chunks; } 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; } 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; } 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()); chunks.add(text.substring(i, end).trim()); } return chunks; } private static class SimpleTokenEstimator implements MarkdownQaSplitter.TokenEstimator { @Override public int estimate(String text) { if (StrUtil.isEmpty(text)) return 0; int chineseChars = 0, englishWords = 0; for (char c : text.toCharArray()) { if (c >= 0x4E00 && c <= 0x9FA5) chineseChars++; } for (String word : text.split("\\s+")) { if (word.matches(".*[a-zA-Z].*")) englishWords++; } return chineseChars + (int)(englishWords * 1.3); } } }