4 天以前 fb5dcaeb2ab91d0f9ffea26fd15ddcbbe5d36bb9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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<String> 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<String> splitText(String text) {
        if (StrUtil.isEmpty(text)) return Collections.emptyList();
        return splitTextRecursive(text);
    }
 
    private List<String> splitTextRecursive(String text) {
        int textTokens = tokenEstimator.estimate(text);
        if (textTokens <= chunkSize) return Collections.singletonList(text.trim());
 
        List<String> 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<String> splitBySentences(String text) {
        List<String> 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<String> mergeSplits(List<String> splits, String separator) {
        List<String> chunks = new ArrayList<>();
        List<String> 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<String> getOverlappingChunks(List<String> chunks, String separator) {
        if (chunkOverlap == 0 || chunks.isEmpty()) return new ArrayList<>();
        List<String> 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<String> 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<String> forceSplitLongText(String text) {
        List<String> 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);
        }
    }
}