2026-06-24 f4bd1f3c89d906131495a0aca5aaf82966378510
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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
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 格式专用切片器
 *
 * <p>功能特点:
 * <ul>
 *   <li>识别二级标题(## )作为问题标记</li>
 *   <li>短 QA 对保持完整(不超过 Token 限制)</li>
 *   <li>长答案智能切分,每个片段保留完整问题作为上下文</li>
 *   <li>支持自定义 Token 估算器</li>
 * </ul>
 *
 * @author runzhen
 */
@Slf4j
@SuppressWarnings("SizeReplaceableByIsEmpty")
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*");
 
    /**
     * 分段的最大 Token 数
     */
    private final int chunkSize;
 
    /**
     * Token 估算器(简单实现:中文按字符数,英文按单词数的 1.3 倍)
     */
    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();
        }
 
        // 解析 QA 对
        List<QaPair> qaPairs = parseQaPairs(text);
        if (CollUtil.isEmpty(qaPairs)) {
            // 如果没有识别到 QA 格式,按段落切分
            return fallbackSplit(text);
        }
 
        // 处理每个 QA 对
        List<String> result = new ArrayList<>();
        for (QaPair qaPair : qaPairs) {
            result.addAll(splitQaPair(qaPair));
        }
        return result;
    }
 
    /**
     * 解析 Markdown QA 对
     *
     * @param content 文本内容
     * @return QA 对列表
     */
    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;
        }
 
        // 提取每个 QA 对
        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 qaText = content.substring(start, end).trim();
            String question = questions.get(i);
            // 提取答案部分(去掉问题标题)
            String answer = qaText.substring(qaText.indexOf('\n') + 1).trim();
            qaPairs.add(new QaPair(question, answer, qaText));
        }
        return qaPairs;
    }
 
    /**
     * 切分单个 QA 对
     *
     * @param qaPair QA 对
     * @return 切分后的文本片段列表
     */
    private List<String> splitQaPair(QaPair qaPair) {
        // 如果整个 QA 对不超过限制,保持完整
        List<String> chunks = new ArrayList<>();
        String fullQa = qaPair.fullText;
        int qaTokens = tokenEstimator.estimate(fullQa);
        if (qaTokens <= chunkSize) {
            chunks.add(fullQa);
            return chunks;
        }
 
        // 长答案需要切分
        log.debug("QA 对超过 Token 限制 ({} > {}),开始智能切分: {}", qaTokens, chunkSize, qaPair.question);
        List<String> answerChunks = splitLongAnswer(qaPair.answer, qaPair.question);
        for (String answerChunk : answerChunks) {
            // 每个片段都包含完整问题
            String chunkText = "## " + qaPair.question + "\n" + answerChunk;
            chunks.add(chunkText);
        }
        return chunks;
    }
 
    /**
     * 切分长答案
     *
     * @param answer 答案文本
     * @param question 问题文本
     * @return 切分后的答案片段列表
     */
    private List<String> splitLongAnswer(String answer, String question) {
        List<String> chunks = new ArrayList<>();
        // 预留问题的 Token 空间
        String questionHeader = "## " + question + "\n";
        int questionTokens = tokenEstimator.estimate(questionHeader);
        int availableTokens = chunkSize - questionTokens - 10; // 预留 10 个 Token 的缓冲
 
        // 先按段落切分
        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;
    }
 
    /**
     * 切分长段落(按句子)
     *
     * @param paragraph 段落文本
     * @param availableTokens 可用的 Token 数
     * @return 切分后的文本片段列表
     */
    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;
    }
 
    /**
     * 降级切分策略(当未识别到 QA 格式时)
     *
     * @param content 文本内容
     * @return 切分后的文本片段列表
     */
    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;
    }
 
    /**
     * QA 对数据结构
     */
    @AllArgsConstructor
    private static class QaPair {
 
        String question;
        String answer;
        String fullText;
 
    }
 
    /**
     * Token 估算器接口
     */
    public interface TokenEstimator {
 
        int estimate(String text);
 
    }
 
    /**
     * 简单的 Token 估算器实现
     * 中文:1 字符 ≈ 1 Token
     * 英文:1 单词 ≈ 1.3 Token
     */
    private static class SimpleTokenEstimator implements 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);
        }
    }
 
}