Commit ed136ff02224f9763c373d7c62b12e7a9cdeef98

Authored by YunaiV
1 parent 4af529cb

feat:【ai】增加智能文档切片策略,支持自动识别 Markdown QA 和语义化切分「代码优化」

... ... @@ -23,7 +23,7 @@
23 23 <!-- <module>yudao-module-mall</module>-->
24 24 <!-- <module>yudao-module-crm</module>-->
25 25 <!-- <module>yudao-module-erp</module>-->
26   - <module>yudao-module-ai</module>
  26 +<!-- <module>yudao-module-ai</module>-->
27 27 <!-- <module>yudao-module-iot</module>-->
28 28 </modules>
29 29  
... ...
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/enums/AiDocumentSplitStrategyEnum.java
... ... @@ -50,16 +50,4 @@ public enum AiDocumentSplitStrategyEnum {
50 50 */
51 51 private final String name;
52 52  
53   - /**
54   - * 根据代码获取枚举
55   - */
56   - public static AiDocumentSplitStrategyEnum fromCode(String code) {
57   - for (AiDocumentSplitStrategyEnum strategy : values()) {
58   - if (strategy.getCode().equals(code)) {
59   - return strategy;
60   - }
61   - }
62   - return AUTO; // 默认返回自动识别
63   - }
64   -
65 53 }
... ...
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/service/knowledge/AiKnowledgeSegmentServiceImpl.java
... ... @@ -107,11 +107,8 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
107 107 if (StrUtil.isEmpty(segment.getText())) {
108 108 return null;
109 109 }
110   - return new AiKnowledgeSegmentDO()
111   - .setKnowledgeId(documentDO.getKnowledgeId())
112   - .setDocumentId(documentId)
113   - .setContent(segment.getText())
114   - .setContentLength(segment.getText().length())
  110 + return new AiKnowledgeSegmentDO().setKnowledgeId(documentDO.getKnowledgeId()).setDocumentId(documentId)
  111 + .setContent(segment.getText()).setContentLength(segment.getText().length())
115 112 .setVectorId(AiKnowledgeSegmentDO.VECTOR_ID_EMPTY)
116 113 .setTokens(tokenCountEstimator.estimate(segment.getText()))
117 114 .setStatus(CommonStatusEnum.ENABLE.getStatus());
... ... @@ -302,13 +299,12 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
302 299 // 1. 读取 URL 内容
303 300 String content = knowledgeDocumentService.readUrl(url);
304 301  
305   - // 2. 自动检测文档类型并选择策略
  302 + // 2.1 自动检测文档类型并选择策略
306 303 AiDocumentSplitStrategyEnum strategy = detectDocumentStrategy(content, url);
307   -
308   - // 3. 文档切片
  304 + // 2.2 文档切片
309 305 List<Document> documentSegments = splitContentByStrategy(content, segmentMaxTokens, strategy, url);
310 306  
311   - // 4. 转换为段落对象
  307 + // 3. 转换为段落对象
312 308 return convertList(documentSegments, segment -> {
313 309 if (StrUtil.isEmpty(segment.getText())) {
314 310 return null;
... ... @@ -352,6 +348,7 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
352 348 * @param url 文档 URL(用于自动检测文件类型)
353 349 * @return 切片后的文档列表
354 350 */
  351 + @SuppressWarnings("EnhancedSwitchMigration")
355 352 private List<Document> splitContentByStrategy(String content, Integer segmentMaxTokens,
356 353 AiDocumentSplitStrategyEnum strategy, String url) {
357 354 // 自动检测策略
... ... @@ -359,7 +356,7 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
359 356 strategy = detectDocumentStrategy(content, url);
360 357 log.info("[splitContentByStrategy][自动检测到文档策略: {}]", strategy.getName());
361 358 }
362   -
  359 + // 根据策略切分
363 360 TextSplitter textSplitter;
364 361 switch (strategy) {
365 362 case MARKDOWN_QA:
... ... @@ -376,7 +373,7 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
376 373 textSplitter = buildTokenTextSplitter(segmentMaxTokens);
377 374 break;
378 375 }
379   -
  376 + // 执行切分
380 377 return textSplitter.apply(Collections.singletonList(new Document(content)));
381 378 }
382 379  
... ... @@ -391,17 +388,14 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
391 388 if (StrUtil.isEmpty(content)) {
392 389 return AiDocumentSplitStrategyEnum.TOKEN;
393 390 }
394   -
395 391 // 1. 检测 Markdown QA 格式
396 392 if (isMarkdownQaFormat(content, url)) {
397 393 return AiDocumentSplitStrategyEnum.MARKDOWN_QA;
398 394 }
399   -
400 395 // 2. 检测普通 Markdown 文档
401 396 if (isMarkdownDocument(url)) {
402 397 return AiDocumentSplitStrategyEnum.SEMANTIC;
403 398 }
404   -
405 399 // 3. 默认使用语义切分(比 Token 切分更智能)
406 400 return AiDocumentSplitStrategyEnum.SEMANTIC;
407 401 }
... ... @@ -421,16 +415,14 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
421 415 .filter(line -> line.trim().startsWith("## "))
422 416 .count();
423 417  
424   - // 至少包含 2 个二级标题才认为是 QA 格式
  418 + // 要求一:至少包含 2 个二级标题才认为是 QA 格式
425 419 if (h2Count < 2) {
426 420 return false;
427 421 }
428 422  
429   - // 检查标题占比(QA 文档标题行数相对较多)
  423 + // 要求二:检查标题占比(QA 文档标题行数相对较多),如果二级标题占比超过 10%,认为是 QA 格式
430 424 long totalLines = content.lines().count();
431 425 double h2Ratio = (double) h2Count / totalLines;
432   -
433   - // 如果二级标题占比超过 10%,认为是 QA 格式
434 426 return h2Ratio > 0.1;
435 427 }
436 428  
... ... @@ -438,7 +430,7 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
438 430 * 检测是否为 Markdown 文档
439 431 */
440 432 private boolean isMarkdownDocument(String url) {
441   - return StrUtil.isNotEmpty(url) && url.toLowerCase().endsWith(".md");
  433 + return StrUtil.endWithAnyIgnoreCase(url, ".md", ".markdown");
442 434 }
443 435  
444 436 /**
... ...
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/service/knowledge/splitter/MarkdownQaSplitter.java
1 1 package cn.iocoder.yudao.module.ai.service.knowledge.splitter;
2 2  
  3 +import cn.hutool.core.collection.CollUtil;
3 4 import cn.hutool.core.util.StrUtil;
  5 +import lombok.AllArgsConstructor;
4 6 import lombok.extern.slf4j.Slf4j;
5 7 import org.springframework.ai.transformer.splitter.TextSplitter;
6 8  
... ... @@ -24,6 +26,7 @@ import java.util.regex.Pattern;
24 26 * @author runzhen
25 27 */
26 28 @Slf4j
  29 +@SuppressWarnings("SizeReplaceableByIsEmpty")
27 30 public class MarkdownQaSplitter extends TextSplitter {
28 31  
29 32 /**
... ... @@ -62,41 +65,38 @@ public class MarkdownQaSplitter extends TextSplitter {
62 65 return Collections.emptyList();
63 66 }
64 67  
65   - List<String> result = new ArrayList<>();
66   -
67 68 // 解析 QA 对
68 69 List<QaPair> qaPairs = parseQaPairs(text);
69   -
70   - if (qaPairs.isEmpty()) {
  70 + if (CollUtil.isEmpty(qaPairs)) {
71 71 // 如果没有识别到 QA 格式,按段落切分
72 72 return fallbackSplit(text);
73 73 }
74 74  
75 75 // 处理每个 QA 对
  76 + List<String> result = new ArrayList<>();
76 77 for (QaPair qaPair : qaPairs) {
77 78 result.addAll(splitQaPair(qaPair));
78 79 }
79   -
80 80 return result;
81 81 }
82 82  
83 83 /**
84 84 * 解析 Markdown QA 对
  85 + *
  86 + * @param content 文本内容
  87 + * @return QA 对列表
85 88 */
86 89 private List<QaPair> parseQaPairs(String content) {
  90 + // 找到所有二级标题位置
87 91 List<QaPair> qaPairs = new ArrayList<>();
88   - Matcher matcher = H2_PATTERN.matcher(content);
89   -
90 92 List<Integer> headingPositions = new ArrayList<>();
91 93 List<String> questions = new ArrayList<>();
92   -
93   - // 找到所有二级标题位置
  94 + Matcher matcher = H2_PATTERN.matcher(content);
94 95 while (matcher.find()) {
95 96 headingPositions.add(matcher.start());
96 97 questions.add(matcher.group(1).trim());
97 98 }
98   -
99   - if (headingPositions.isEmpty()) {
  99 + if (CollUtil.isEmpty(headingPositions)) {
100 100 return qaPairs;
101 101 }
102 102  
... ... @@ -106,55 +106,51 @@ public class MarkdownQaSplitter extends TextSplitter {
106 106 int end = (i + 1 < headingPositions.size())
107 107 ? headingPositions.get(i + 1)
108 108 : content.length();
109   -
110 109 String qaText = content.substring(start, end).trim();
111 110 String question = questions.get(i);
112   -
113 111 // 提取答案部分(去掉问题标题)
114 112 String answer = qaText.substring(qaText.indexOf('\n') + 1).trim();
115   -
116 113 qaPairs.add(new QaPair(question, answer, qaText));
117 114 }
118   -
119 115 return qaPairs;
120 116 }
121 117  
122 118 /**
123 119 * 切分单个 QA 对
  120 + *
  121 + * @param qaPair QA 对
  122 + * @return 切分后的文本片段列表
124 123 */
125 124 private List<String> splitQaPair(QaPair qaPair) {
  125 + // 如果整个 QA 对不超过限制,保持完整
126 126 List<String> chunks = new ArrayList<>();
127   -
128 127 String fullQa = qaPair.fullText;
129 128 int qaTokens = tokenEstimator.estimate(fullQa);
130   -
131   - // 如果整个 QA 对不超过限制,保持完整
132 129 if (qaTokens <= chunkSize) {
133 130 chunks.add(fullQa);
134 131 return chunks;
135 132 }
136 133  
137 134 // 长答案需要切分
138   - log.debug("QA 对超过 Token 限制 ({} > {}),开始智能切分: {}",
139   - qaTokens, chunkSize, qaPair.question);
140   -
  135 + log.debug("QA 对超过 Token 限制 ({} > {}),开始智能切分: {}", qaTokens, chunkSize, qaPair.question);
141 136 List<String> answerChunks = splitLongAnswer(qaPair.answer, qaPair.question);
142   -
143 137 for (String answerChunk : answerChunks) {
144 138 // 每个片段都包含完整问题
145 139 String chunkText = "## " + qaPair.question + "\n" + answerChunk;
146 140 chunks.add(chunkText);
147 141 }
148   -
149 142 return chunks;
150 143 }
151 144  
152 145 /**
153 146 * 切分长答案
  147 + *
  148 + * @param answer 答案文本
  149 + * @param question 问题文本
  150 + * @return 切分后的答案片段列表
154 151 */
155 152 private List<String> splitLongAnswer(String answer, String question) {
156 153 List<String> chunks = new ArrayList<>();
157   -
158 154 // 预留问题的 Token 空间
159 155 String questionHeader = "## " + question + "\n";
160 156 int questionTokens = tokenEstimator.estimate(questionHeader);
... ... @@ -162,17 +158,13 @@ public class MarkdownQaSplitter extends TextSplitter {
162 158  
163 159 // 先按段落切分
164 160 String[] paragraphs = answer.split(PARAGRAPH_SEPARATOR);
165   -
166 161 StringBuilder currentChunk = new StringBuilder();
167 162 int currentTokens = 0;
168   -
169 163 for (String paragraph : paragraphs) {
170 164 if (StrUtil.isEmpty(paragraph)) {
171 165 continue;
172 166 }
173   -
174 167 int paragraphTokens = tokenEstimator.estimate(paragraph);
175   -
176 168 // 如果单个段落就超过限制,需要按句子切分
177 169 if (paragraphTokens > availableTokens) {
178 170 // 先保存当前块
... ... @@ -181,22 +173,20 @@ public class MarkdownQaSplitter extends TextSplitter {
181 173 currentChunk = new StringBuilder();
182 174 currentTokens = 0;
183 175 }
184   -
185 176 // 按句子切分长段落
186 177 chunks.addAll(splitLongParagraph(paragraph, availableTokens));
187 178 continue;
188 179 }
189   -
190 180 // 如果加上这个段落会超过限制
191 181 if (currentTokens + paragraphTokens > availableTokens && currentChunk.length() > 0) {
192 182 chunks.add(currentChunk.toString().trim());
193 183 currentChunk = new StringBuilder();
194 184 currentTokens = 0;
195 185 }
196   -
197 186 if (currentChunk.length() > 0) {
198 187 currentChunk.append("\n\n");
199 188 }
  189 + // 添加段落
200 190 currentChunk.append(paragraph);
201 191 currentTokens += paragraphTokens;
202 192 }
... ... @@ -205,27 +195,29 @@ public class MarkdownQaSplitter extends TextSplitter {
205 195 if (currentChunk.length() > 0) {
206 196 chunks.add(currentChunk.toString().trim());
207 197 }
208   -
209   - return chunks.isEmpty() ? Collections.singletonList(answer) : chunks;
  198 + return CollUtil.isEmpty(chunks) ? Collections.singletonList(answer) : chunks;
210 199 }
211 200  
212 201 /**
213 202 * 切分长段落(按句子)
  203 + *
  204 + * @param paragraph 段落文本
  205 + * @param availableTokens 可用的 Token 数
  206 + * @return 切分后的文本片段列表
214 207 */
215 208 private List<String> splitLongParagraph(String paragraph, int availableTokens) {
  209 + // 按句子切分
216 210 List<String> chunks = new ArrayList<>();
217 211 String[] sentences = SENTENCE_PATTERN.split(paragraph);
218 212  
  213 + // 按句子累积切分
219 214 StringBuilder currentChunk = new StringBuilder();
220 215 int currentTokens = 0;
221   -
222 216 for (String sentence : sentences) {
223 217 if (StrUtil.isEmpty(sentence)) {
224 218 continue;
225 219 }
226   -
227 220 int sentenceTokens = tokenEstimator.estimate(sentence);
228   -
229 221 // 如果单个句子就超过限制,强制切分
230 222 if (sentenceTokens > availableTokens) {
231 223 if (currentChunk.length() > 0) {
... ... @@ -236,47 +228,50 @@ public class MarkdownQaSplitter extends TextSplitter {
236 228 chunks.add(sentence.trim());
237 229 continue;
238 230 }
239   -
  231 + // 如果加上这个句子会超过限制
240 232 if (currentTokens + sentenceTokens > availableTokens && currentChunk.length() > 0) {
241 233 chunks.add(currentChunk.toString().trim());
242 234 currentChunk = new StringBuilder();
243 235 currentTokens = 0;
244 236 }
245   -
  237 + // 添加句子
246 238 currentChunk.append(sentence);
247 239 currentTokens += sentenceTokens;
248 240 }
249 241  
  242 + // 添加最后一块
250 243 if (currentChunk.length() > 0) {
251 244 chunks.add(currentChunk.toString().trim());
252 245 }
253   -
254 246 return chunks.isEmpty() ? Collections.singletonList(paragraph) : chunks;
255 247 }
256 248  
257 249 /**
258 250 * 降级切分策略(当未识别到 QA 格式时)
  251 + *
  252 + * @param content 文本内容
  253 + * @return 切分后的文本片段列表
259 254 */
260 255 private List<String> fallbackSplit(String content) {
  256 + // 按段落切分
261 257 List<String> chunks = new ArrayList<>();
262 258 String[] paragraphs = content.split(PARAGRAPH_SEPARATOR);
263 259  
  260 + // 按段落累积切分
264 261 StringBuilder currentChunk = new StringBuilder();
265 262 int currentTokens = 0;
266   -
267 263 for (String paragraph : paragraphs) {
268 264 if (StrUtil.isEmpty(paragraph)) {
269 265 continue;
270 266 }
271   -
272 267 int paragraphTokens = tokenEstimator.estimate(paragraph);
273   -
  268 + // 如果加上这个段落会超过限制
274 269 if (currentTokens + paragraphTokens > chunkSize && currentChunk.length() > 0) {
275 270 chunks.add(currentChunk.toString().trim());
276 271 currentChunk = new StringBuilder();
277 272 currentTokens = 0;
278 273 }
279   -
  274 + // 添加段落
280 275 if (currentChunk.length() > 0) {
281 276 currentChunk.append("\n\n");
282 277 }
... ... @@ -284,33 +279,32 @@ public class MarkdownQaSplitter extends TextSplitter {
284 279 currentTokens += paragraphTokens;
285 280 }
286 281  
  282 + // 添加最后一块
287 283 if (currentChunk.length() > 0) {
288 284 chunks.add(currentChunk.toString().trim());
289 285 }
290   -
291 286 return chunks.isEmpty() ? Collections.singletonList(content) : chunks;
292 287 }
293 288  
294 289 /**
295 290 * QA 对数据结构
296 291 */
  292 + @AllArgsConstructor
297 293 private static class QaPair {
  294 +
298 295 String question;
299 296 String answer;
300 297 String fullText;
301 298  
302   - QaPair(String question, String answer, String fullText) {
303   - this.question = question;
304   - this.answer = answer;
305   - this.fullText = fullText;
306   - }
307 299 }
308 300  
309 301 /**
310 302 * Token 估算器接口
311 303 */
312 304 public interface TokenEstimator {
  305 +
313 306 int estimate(String text);
  307 +
314 308 }
315 309  
316 310 /**
... ... @@ -319,6 +313,7 @@ public class MarkdownQaSplitter extends TextSplitter {
319 313 * 英文:1 单词 ≈ 1.3 Token
320 314 */
321 315 private static class SimpleTokenEstimator implements TokenEstimator {
  316 +
322 317 @Override
323 318 public int estimate(String text) {
324 319 if (StrUtil.isEmpty(text)) {
... ... @@ -327,14 +322,12 @@ public class MarkdownQaSplitter extends TextSplitter {
327 322  
328 323 int chineseChars = 0;
329 324 int englishWords = 0;
330   -
331 325 // 简单统计中英文
332 326 for (char c : text.toCharArray()) {
333 327 if (c >= 0x4E00 && c <= 0x9FA5) {
334 328 chineseChars++;
335 329 }
336 330 }
337   -
338 331 // 英文单词估算
339 332 String[] words = text.split("\\s+");
340 333 for (String word : words) {
... ... @@ -342,8 +335,8 @@ public class MarkdownQaSplitter extends TextSplitter {
342 335 englishWords++;
343 336 }
344 337 }
345   -
346 338 return chineseChars + (int) (englishWords * 1.3);
347 339 }
348 340 }
  341 +
349 342 }
... ...
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/service/knowledge/splitter/SemanticTextSplitter.java
... ... @@ -8,6 +8,7 @@ import java.util.ArrayList;
8 8 import java.util.Arrays;
9 9 import java.util.Collections;
10 10 import java.util.List;
  11 +import java.util.regex.Matcher;
11 12 import java.util.regex.Pattern;
12 13  
13 14 /**
... ... @@ -72,12 +73,14 @@ public class SemanticTextSplitter extends TextSplitter {
72 73 if (StrUtil.isEmpty(text)) {
73 74 return Collections.emptyList();
74 75 }
75   -
76 76 return splitTextRecursive(text);
77 77 }
78 78  
79 79 /**
80 80 * 切分文本(递归策略)
  81 + *
  82 + * @param text 待切分文本
  83 + * @return 切分后的文本块列表
81 84 */
82 85 private List<String> splitTextRecursive(String text) {
83 86 List<String> chunks = new ArrayList<>();
... ... @@ -92,7 +95,6 @@ public class SemanticTextSplitter extends TextSplitter {
92 95 // 尝试按不同分隔符切分
93 96 List<String> splits = null;
94 97 String usedSeparator = null;
95   -
96 98 for (String separator : PARAGRAPH_SEPARATORS) {
97 99 if (text.contains(separator)) {
98 100 splits = Arrays.asList(text.split(Pattern.quote(separator)));
... ... @@ -109,18 +111,20 @@ public class SemanticTextSplitter extends TextSplitter {
109 111  
110 112 // 合并小片段
111 113 chunks = mergeSplits(splits, usedSeparator);
112   -
113 114 return chunks;
114 115 }
115 116  
116 117 /**
117 118 * 按句子切分
  119 + *
  120 + * @param text 待切分文本
  121 + * @return 句子列表
118 122 */
119 123 private List<String> splitBySentences(String text) {
  124 + // 使用正则表达式匹配句子结束位置
120 125 List<String> sentences = new ArrayList<>();
121 126 int lastEnd = 0;
122   -
123   - java.util.regex.Matcher matcher = SENTENCE_END_PATTERN.matcher(text);
  127 + Matcher matcher = SENTENCE_END_PATTERN.matcher(text);
124 128 while (matcher.find()) {
125 129 String sentence = text.substring(lastEnd, matcher.end()).trim();
126 130 if (StrUtil.isNotEmpty(sentence)) {
... ... @@ -136,12 +140,15 @@ public class SemanticTextSplitter extends TextSplitter {
136 140 sentences.add(remaining);
137 141 }
138 142 }
139   -
140 143 return sentences.isEmpty() ? Collections.singletonList(text) : sentences;
141 144 }
142 145  
143 146 /**
144 147 * 合并切分后的小片段
  148 + *
  149 + * @param splits 切分后的片段列表
  150 + * @param separator 片段间的分隔符
  151 + * @return 合并后的文本块列表
145 152 */
146 153 private List<String> mergeSplits(List<String> splits, String separator) {
147 154 List<String> chunks = new ArrayList<>();
... ... @@ -152,9 +159,7 @@ public class SemanticTextSplitter extends TextSplitter {
152 159 if (StrUtil.isEmpty(split)) {
153 160 continue;
154 161 }
155   -
156 162 int splitTokens = tokenEstimator.estimate(split);
157   -
158 163 // 如果单个片段就超过限制,进一步递归切分
159 164 if (splitTokens > chunkSize) {
160 165 // 先保存当前累积的块
... ... @@ -164,7 +169,6 @@ public class SemanticTextSplitter extends TextSplitter {
164 169 currentChunks.clear();
165 170 currentLength = 0;
166 171 }
167   -
168 172 // 递归切分大片段
169 173 if (!separator.isEmpty()) {
170 174 // 如果是段落分隔符,尝试按句子切分
... ... @@ -175,10 +179,8 @@ public class SemanticTextSplitter extends TextSplitter {
175 179 }
176 180 continue;
177 181 }
178   -
179 182 // 计算加上分隔符的 Token 数
180 183 int separatorTokens = StrUtil.isEmpty(separator) ? 0 : tokenEstimator.estimate(separator);
181   -
182 184 // 如果加上这个片段会超过限制
183 185 if (!currentChunks.isEmpty() && currentLength + splitTokens + separatorTokens > chunkSize) {
184 186 // 保存当前块
... ... @@ -189,7 +191,7 @@ public class SemanticTextSplitter extends TextSplitter {
189 191 currentChunks = getOverlappingChunks(currentChunks, separator);
190 192 currentLength = estimateTokens(currentChunks, separator);
191 193 }
192   -
  194 + // 添加当前片段
193 195 currentChunks.add(split);
194 196 currentLength += splitTokens + separatorTokens;
195 197 }
... ... @@ -199,39 +201,43 @@ public class SemanticTextSplitter extends TextSplitter {
199 201 String chunkText = String.join(separator, currentChunks);
200 202 chunks.add(chunkText.trim());
201 203 }
202   -
203 204 return chunks;
204 205 }
205 206  
206 207 /**
207 208 * 获取重叠的片段(用于保持上下文)
  209 + *
  210 + * @param chunks 当前片段列表
  211 + * @param separator 片段间的分隔符
  212 + * @return 重叠的片段列表
208 213 */
209 214 private List<String> getOverlappingChunks(List<String> chunks, String separator) {
210 215 if (chunkOverlap == 0 || chunks.isEmpty()) {
211 216 return new ArrayList<>();
212 217 }
213 218  
  219 + // 从后往前取片段,直到达到重叠大小
214 220 List<String> overlapping = new ArrayList<>();
215 221 int tokens = 0;
216   -
217   - // 从后往前取片段,直到达到重叠大小
218 222 for (int i = chunks.size() - 1; i >= 0; i--) {
219 223 String chunk = chunks.get(i);
220 224 int chunkTokens = tokenEstimator.estimate(chunk);
221   -
222 225 if (tokens + chunkTokens > chunkOverlap) {
223 226 break;
224 227 }
225   -
  228 + // 添加到重叠列表前端
226 229 overlapping.add(0, chunk);
227 230 tokens += chunkTokens + (StrUtil.isEmpty(separator) ? 0 : tokenEstimator.estimate(separator));
228 231 }
229   -
230 232 return overlapping;
231 233 }
232 234  
233 235 /**
234 236 * 估算片段列表的总 Token 数
  237 + *
  238 + * @param chunks 片段列表
  239 + * @param separator 片段间的分隔符
  240 + * @return 总 Token 数
235 241 */
236 242 private int estimateTokens(List<String> chunks, String separator) {
237 243 int total = 0;
... ... @@ -246,17 +252,18 @@ public class SemanticTextSplitter extends TextSplitter {
246 252  
247 253 /**
248 254 * 强制切分长文本(当语义切分失败时)
  255 + *
  256 + * @param text 待切分文本
  257 + * @return 切分后的文本块列表
249 258 */
250 259 private List<String> forceSplitLongText(String text) {
251 260 List<String> chunks = new ArrayList<>();
252 261 int charsPerChunk = (int) (chunkSize * 0.8); // 保守估计
253   -
254 262 for (int i = 0; i < text.length(); i += charsPerChunk) {
255 263 int end = Math.min(i + charsPerChunk, text.length());
256 264 String chunk = text.substring(i, end);
257 265 chunks.add(chunk.trim());
258 266 }
259   -
260 267 log.warn("文本过长,已强制按字符切分,可能影响语义完整性");
261 268 return chunks;
262 269 }
... ... @@ -265,6 +272,7 @@ public class SemanticTextSplitter extends TextSplitter {
265 272 * 简单的 Token 估算器实现
266 273 */
267 274 private static class SimpleTokenEstimator implements MarkdownQaSplitter.TokenEstimator {
  275 +
268 276 @Override
269 277 public int estimate(String text) {
270 278 if (StrUtil.isEmpty(text)) {
... ... @@ -273,21 +281,21 @@ public class SemanticTextSplitter extends TextSplitter {
273 281  
274 282 int chineseChars = 0;
275 283 int englishWords = 0;
276   -
  284 + // 简单统计中英文
277 285 for (char c : text.toCharArray()) {
278 286 if (c >= 0x4E00 && c <= 0x9FA5) {
279 287 chineseChars++;
280 288 }
281 289 }
282   -
  290 + // 英文单词估算
283 291 String[] words = text.split("\\s+");
284 292 for (String word : words) {
285 293 if (word.matches(".*[a-zA-Z].*")) {
286 294 englishWords++;
287 295 }
288 296 }
289   -
290 297 return chineseChars + (int) (englishWords * 1.3);
291 298 }
292 299 }
  300 +
293 301 }
... ...