|
4 | 4 | import cn.hutool.core.collection.ListUtil; |
5 | 5 | import cn.hutool.core.util.ObjUtil; |
6 | 6 | import cn.hutool.core.util.StrUtil; |
| 7 | + |
7 | 8 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
8 | 9 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
9 | 10 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
|
15 | 16 | import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDocumentDO; |
16 | 17 | import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeSegmentDO; |
17 | 18 | import cn.iocoder.yudao.module.ai.dal.mysql.knowledge.AiKnowledgeSegmentMapper; |
| 19 | +import cn.iocoder.yudao.module.ai.enums.AiDocumentSplitStrategyEnum; |
18 | 20 | import cn.iocoder.yudao.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchReqBO; |
19 | 21 | import cn.iocoder.yudao.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchRespBO; |
| 22 | +import cn.iocoder.yudao.module.ai.service.knowledge.splitter.MarkdownQaSplitter; |
| 23 | +import cn.iocoder.yudao.module.ai.service.knowledge.splitter.SemanticTextSplitter; |
20 | 24 | import cn.iocoder.yudao.module.ai.service.model.AiModelService; |
21 | 25 | import com.alibaba.cloud.ai.dashscope.rerank.DashScopeRerankOptions; |
22 | 26 | import com.alibaba.cloud.ai.model.RerankModel; |
|
39 | 43 |
|
40 | 44 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
41 | 45 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
42 | | -import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.KNOWLEDGE_SEGMENT_CONTENT_TOO_LONG; |
43 | | -import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.KNOWLEDGE_SEGMENT_NOT_EXISTS; |
| 46 | +import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.*; |
44 | 47 | import static org.springframework.ai.vectorstore.SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL; |
45 | 48 |
|
46 | 49 | /** |
@@ -95,8 +98,9 @@ public void createKnowledgeSegmentBySplitContent(Long documentId, String content |
95 | 98 | AiKnowledgeDO knowledgeDO = knowledgeService.validateKnowledgeExists(documentDO.getKnowledgeId()); |
96 | 99 | VectorStore vectorStore = getVectorStoreById(knowledgeDO); |
97 | 100 |
|
98 | | - // 2. 文档切片 |
99 | | - List<Document> documentSegments = splitContentByToken(content, documentDO.getSegmentMaxTokens()); |
| 101 | + // 2. 文档切片(使用自动检测策略) |
| 102 | + List<Document> documentSegments = splitContentByStrategy(content, documentDO.getSegmentMaxTokens(), |
| 103 | + AiDocumentSplitStrategyEnum.AUTO, documentDO.getUrl()); |
100 | 104 |
|
101 | 105 | // 3.1 存储切片 |
102 | 106 | List<AiKnowledgeSegmentDO> segmentDOs = convertList(documentSegments, segment -> { |
@@ -295,8 +299,10 @@ public List<AiKnowledgeSegmentDO> splitContent(String url, Integer segmentMaxTok |
295 | 299 | // 1. 读取 URL 内容 |
296 | 300 | String content = knowledgeDocumentService.readUrl(url); |
297 | 301 |
|
298 | | - // 2. 文档切片 |
299 | | - List<Document> documentSegments = splitContentByToken(content, segmentMaxTokens); |
| 302 | + // 2.1 自动检测文档类型并选择策略 |
| 303 | + AiDocumentSplitStrategyEnum strategy = detectDocumentStrategy(content, url); |
| 304 | + // 2.2 文档切片 |
| 305 | + List<Document> documentSegments = splitContentByStrategy(content, segmentMaxTokens, strategy, url); |
300 | 306 |
|
301 | 307 | // 3. 转换为段落对象 |
302 | 308 | return convertList(documentSegments, segment -> { |
@@ -333,11 +339,103 @@ private VectorStore getVectorStoreById(Long knowledgeId) { |
333 | 339 | return getVectorStoreById(knowledge); |
334 | 340 | } |
335 | 341 |
|
336 | | - private static List<Document> splitContentByToken(String content, Integer segmentMaxTokens) { |
337 | | - TextSplitter textSplitter = buildTokenTextSplitter(segmentMaxTokens); |
| 342 | + /** |
| 343 | + * 根据策略切分内容 |
| 344 | + * |
| 345 | + * @param content 文档内容 |
| 346 | + * @param segmentMaxTokens 分段的最大 Token 数 |
| 347 | + * @param strategy 切片策略 |
| 348 | + * @param url 文档 URL(用于自动检测文件类型) |
| 349 | + * @return 切片后的文档列表 |
| 350 | + */ |
| 351 | + @SuppressWarnings("EnhancedSwitchMigration") |
| 352 | + private List<Document> splitContentByStrategy(String content, Integer segmentMaxTokens, |
| 353 | + AiDocumentSplitStrategyEnum strategy, String url) { |
| 354 | + // 自动检测策略 |
| 355 | + if (strategy == AiDocumentSplitStrategyEnum.AUTO) { |
| 356 | + strategy = detectDocumentStrategy(content, url); |
| 357 | + log.info("[splitContentByStrategy][自动检测到文档策略: {}]", strategy.getName()); |
| 358 | + } |
| 359 | + // 根据策略切分 |
| 360 | + TextSplitter textSplitter; |
| 361 | + switch (strategy) { |
| 362 | + case MARKDOWN_QA: |
| 363 | + textSplitter = new MarkdownQaSplitter(segmentMaxTokens); |
| 364 | + break; |
| 365 | + case SEMANTIC: |
| 366 | + textSplitter = new SemanticTextSplitter(segmentMaxTokens); |
| 367 | + break; |
| 368 | + case PARAGRAPH: |
| 369 | + textSplitter = new SemanticTextSplitter(segmentMaxTokens, 0); // 段落切分,无重叠 |
| 370 | + break; |
| 371 | + case TOKEN: |
| 372 | + default: |
| 373 | + textSplitter = buildTokenTextSplitter(segmentMaxTokens); |
| 374 | + break; |
| 375 | + } |
| 376 | + // 执行切分 |
338 | 377 | return textSplitter.apply(Collections.singletonList(new Document(content))); |
339 | 378 | } |
340 | 379 |
|
| 380 | + /** |
| 381 | + * 自动检测文档类型并选择切片策略 |
| 382 | + * |
| 383 | + * @param content 文档内容 |
| 384 | + * @param url 文档 URL |
| 385 | + * @return 推荐的切片策略 |
| 386 | + */ |
| 387 | + private AiDocumentSplitStrategyEnum detectDocumentStrategy(String content, String url) { |
| 388 | + if (StrUtil.isEmpty(content)) { |
| 389 | + return AiDocumentSplitStrategyEnum.TOKEN; |
| 390 | + } |
| 391 | + // 1. 检测 Markdown QA 格式 |
| 392 | + if (isMarkdownQaFormat(content, url)) { |
| 393 | + return AiDocumentSplitStrategyEnum.MARKDOWN_QA; |
| 394 | + } |
| 395 | + // 2. 检测普通 Markdown 文档 |
| 396 | + if (isMarkdownDocument(url)) { |
| 397 | + return AiDocumentSplitStrategyEnum.SEMANTIC; |
| 398 | + } |
| 399 | + // 3. 默认使用语义切分(比 Token 切分更智能) |
| 400 | + return AiDocumentSplitStrategyEnum.SEMANTIC; |
| 401 | + } |
| 402 | + |
| 403 | + /** |
| 404 | + * 检测是否为 Markdown QA 格式 |
| 405 | + * 特征:包含多个二级标题(## )且标题后紧跟答案内容 |
| 406 | + */ |
| 407 | + private boolean isMarkdownQaFormat(String content, String url) { |
| 408 | + // 文件扩展名判断 |
| 409 | + if (StrUtil.isNotEmpty(url) && !url.toLowerCase().endsWith(".md")) { |
| 410 | + return false; |
| 411 | + } |
| 412 | + |
| 413 | + // 统计二级标题数量 |
| 414 | + long h2Count = content.lines() |
| 415 | + .filter(line -> line.trim().startsWith("## ")) |
| 416 | + .count(); |
| 417 | + |
| 418 | + // 要求一:至少包含 2 个二级标题才认为是 QA 格式 |
| 419 | + if (h2Count < 2) { |
| 420 | + return false; |
| 421 | + } |
| 422 | + |
| 423 | + // 要求二:检查标题占比(QA 文档标题行数相对较多),如果二级标题占比超过 10%,认为是 QA 格式 |
| 424 | + long totalLines = content.lines().count(); |
| 425 | + double h2Ratio = (double) h2Count / totalLines; |
| 426 | + return h2Ratio > 0.1; |
| 427 | + } |
| 428 | + |
| 429 | + /** |
| 430 | + * 检测是否为 Markdown 文档 |
| 431 | + */ |
| 432 | + private boolean isMarkdownDocument(String url) { |
| 433 | + return StrUtil.endWithAnyIgnoreCase(url, ".md", ".markdown"); |
| 434 | + } |
| 435 | + |
| 436 | + /** |
| 437 | + * 构建基于 Token 的文本切片器(原有逻辑保留) |
| 438 | + */ |
341 | 439 | private static TextSplitter buildTokenTextSplitter(Integer segmentMaxTokens) { |
342 | 440 | return TokenTextSplitter.builder() |
343 | 441 | .withChunkSize(segmentMaxTokens) |
|
0 commit comments