Commit ed136ff02224f9763c373d7c62b12e7a9cdeef98

Authored by YunaiV
1 parent 4af529cb

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

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