3 天以前 83e1b4d0e661f11a407fd6ea86e906b9b87b7180
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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
package cn.iocoder.yudao.module.ai.service.knowledge.splitter;
 
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.transformer.splitter.TextSplitter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
 
/**
 * Markdown QA 格式专用切片器
 * 识别二级标题(## )作为问题标记,保持问答对完整性
 */
@Slf4j
public class MarkdownQaSplitter extends TextSplitter {
 
    private static final Pattern H2_PATTERN = Pattern.compile("^##\\s+(.+)$", Pattern.MULTILINE);
    private static final String PARAGRAPH_SEPARATOR = "\n\n";
    private static final Pattern SENTENCE_PATTERN = Pattern.compile("[。!?.!?]\\s*");
 
    private final int chunkSize;
    private final TokenEstimator tokenEstimator;
 
    public MarkdownQaSplitter(int chunkSize) {
        this.chunkSize = chunkSize;
        this.tokenEstimator = new SimpleTokenEstimator();
    }
 
    @Override
    protected List<String> splitText(String text) {
        if (StrUtil.isEmpty(text)) return Collections.emptyList();
        List<QaPair> qaPairs = parseQaPairs(text);
        if (CollUtil.isEmpty(qaPairs)) return fallbackSplit(text);
        List<String> result = new ArrayList<>();
        for (QaPair qaPair : qaPairs) result.addAll(splitQaPair(qaPair));
        return result;
    }
 
    private List<QaPair> parseQaPairs(String content) {
        List<QaPair> qaPairs = new ArrayList<>();
        List<Integer> headingPositions = new ArrayList<>();
        List<String> questions = new ArrayList<>();
        Matcher matcher = H2_PATTERN.matcher(content);
        while (matcher.find()) { headingPositions.add(matcher.start()); questions.add(matcher.group(1).trim()); }
        if (CollUtil.isEmpty(headingPositions)) return qaPairs;
        for (int i = 0; i < headingPositions.size(); i++) {
            int start = headingPositions.get(i);
            int end = (i + 1 < headingPositions.size()) ? headingPositions.get(i + 1) : content.length();
            String fullText = content.substring(start, end).trim();
            String question = questions.get(i);
            String answer = fullText.substring(fullText.indexOf('\n') + 1).trim();
            qaPairs.add(new QaPair(question, answer, fullText));
        }
        return qaPairs;
    }
 
    private List<String> splitQaPair(QaPair qaPair) {
        int qaTokens = tokenEstimator.estimate(qaPair.fullText);
        if (qaTokens <= chunkSize) return Collections.singletonList(qaPair.fullText);
        List<String> chunks = new ArrayList<>();
        List<String> answerChunks = splitLongAnswer(qaPair.answer, qaPair.question);
        for (String answerChunk : answerChunks) {
            chunks.add("## " + qaPair.question + "\n" + answerChunk);
        }
        return chunks;
    }
 
    private List<String> splitLongAnswer(String answer, String question) {
        List<String> chunks = new ArrayList<>();
        String questionHeader = "## " + question + "\n";
        int questionTokens = tokenEstimator.estimate(questionHeader);
        int availableTokens = chunkSize - questionTokens - 10;
        String[] paragraphs = answer.split(PARAGRAPH_SEPARATOR);
        StringBuilder currentChunk = new StringBuilder();
        int currentTokens = 0;
        for (String paragraph : paragraphs) {
            if (StrUtil.isEmpty(paragraph)) continue;
            int paragraphTokens = tokenEstimator.estimate(paragraph);
            if (paragraphTokens > availableTokens) {
                if (currentChunk.length() > 0) { chunks.add(currentChunk.toString().trim()); currentChunk = new StringBuilder(); currentTokens = 0; }
                chunks.addAll(splitLongParagraph(paragraph, availableTokens));
                continue;
            }
            if (currentTokens + paragraphTokens > availableTokens && currentChunk.length() > 0) { chunks.add(currentChunk.toString().trim()); currentChunk = new StringBuilder(); currentTokens = 0; }
            if (currentChunk.length() > 0) currentChunk.append("\n\n");
            currentChunk.append(paragraph);
            currentTokens += paragraphTokens;
        }
        if (currentChunk.length() > 0) chunks.add(currentChunk.toString().trim());
        return CollUtil.isEmpty(chunks) ? Collections.singletonList(answer) : chunks;
    }
 
    private List<String> splitLongParagraph(String paragraph, int availableTokens) {
        List<String> chunks = new ArrayList<>();
        String[] sentences = SENTENCE_PATTERN.split(paragraph);
        StringBuilder currentChunk = new StringBuilder();
        int currentTokens = 0;
        for (String sentence : sentences) {
            if (StrUtil.isEmpty(sentence)) continue;
            int sentenceTokens = tokenEstimator.estimate(sentence);
            if (sentenceTokens > availableTokens) { if (currentChunk.length() > 0) { chunks.add(currentChunk.toString().trim()); currentChunk = new StringBuilder(); currentTokens = 0; } chunks.add(sentence.trim()); continue; }
            if (currentTokens + sentenceTokens > availableTokens && currentChunk.length() > 0) { chunks.add(currentChunk.toString().trim()); currentChunk = new StringBuilder(); currentTokens = 0; }
            currentChunk.append(sentence);
            currentTokens += sentenceTokens;
        }
        if (currentChunk.length() > 0) chunks.add(currentChunk.toString().trim());
        return chunks.isEmpty() ? Collections.singletonList(paragraph) : chunks;
    }
 
    private List<String> fallbackSplit(String content) {
        List<String> chunks = new ArrayList<>();
        String[] paragraphs = content.split(PARAGRAPH_SEPARATOR);
        StringBuilder currentChunk = new StringBuilder();
        int currentTokens = 0;
        for (String paragraph : paragraphs) {
            if (StrUtil.isEmpty(paragraph)) continue;
            int paragraphTokens = tokenEstimator.estimate(paragraph);
            if (currentTokens + paragraphTokens > chunkSize && currentChunk.length() > 0) { chunks.add(currentChunk.toString().trim()); currentChunk = new StringBuilder(); currentTokens = 0; }
            if (currentChunk.length() > 0) currentChunk.append("\n\n");
            currentChunk.append(paragraph);
            currentTokens += paragraphTokens;
        }
        if (currentChunk.length() > 0) chunks.add(currentChunk.toString().trim());
        return chunks.isEmpty() ? Collections.singletonList(content) : chunks;
    }
 
    @AllArgsConstructor
    private static class QaPair { String question; String answer; String fullText; }
 
    public interface TokenEstimator { int estimate(String text); }
 
    private static class SimpleTokenEstimator implements 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);
        }
    }
}