Commit 4af529cb9746b4f12c8a9162c793d3cfe8ba3a81
Committed by
Gitee
!1468 feat:【ai】增加智能文档切片策略,支持自动识别 Markdown QA 和语义化切分
Merge pull request !1468 from shixiaohe/master-jdk17
Showing
6 changed files
with
826 additions
and
13 deletions
pom.xml
| @@ -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/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentPageReqVO.java
| @@ -11,7 +11,7 @@ import lombok.Data; | @@ -11,7 +11,7 @@ import lombok.Data; | ||
| 11 | public class AiKnowledgeSegmentPageReqVO extends PageParam { | 11 | public class AiKnowledgeSegmentPageReqVO extends PageParam { |
| 12 | 12 | ||
| 13 | @Schema(description = "文档编号", example = "1") | 13 | @Schema(description = "文档编号", example = "1") |
| 14 | - private Integer documentId; | 14 | + private Long documentId; |
| 15 | 15 | ||
| 16 | @Schema(description = "分段内容关键字", example = "Java 开发") | 16 | @Schema(description = "分段内容关键字", example = "Java 开发") |
| 17 | private String content; | 17 | private String content; |
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/enums/AiDocumentSplitStrategyEnum.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.ai.enums; | ||
| 2 | + | ||
| 3 | +import lombok.AllArgsConstructor; | ||
| 4 | +import lombok.Getter; | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * AI 知识库文档切片策略枚举 | ||
| 8 | + * | ||
| 9 | + * @author runzhen | ||
| 10 | + */ | ||
| 11 | +@AllArgsConstructor | ||
| 12 | +@Getter | ||
| 13 | +public enum AiDocumentSplitStrategyEnum { | ||
| 14 | + | ||
| 15 | + /** | ||
| 16 | + * 自动识别文档类型并选择最佳切片策略 | ||
| 17 | + */ | ||
| 18 | + AUTO("auto", "自动识别"), | ||
| 19 | + | ||
| 20 | + /** | ||
| 21 | + * 基于 Token 数量机械切分(默认策略) | ||
| 22 | + */ | ||
| 23 | + TOKEN("token", "Token 切分"), | ||
| 24 | + | ||
| 25 | + /** | ||
| 26 | + * 按段落切分(以双换行符为分隔) | ||
| 27 | + */ | ||
| 28 | + PARAGRAPH("paragraph", "段落切分"), | ||
| 29 | + | ||
| 30 | + /** | ||
| 31 | + * Markdown QA 格式专用切片器 | ||
| 32 | + * 识别二级标题作为问题,保持问答对完整性 | ||
| 33 | + * 长答案智能切分但保留问题作为上下文 | ||
| 34 | + */ | ||
| 35 | + MARKDOWN_QA("markdown_qa", "Markdown QA 切分"), | ||
| 36 | + | ||
| 37 | + /** | ||
| 38 | + * 语义化切分,保留句子完整性 | ||
| 39 | + * 在段落和句子边界处切分,避免截断 | ||
| 40 | + */ | ||
| 41 | + SEMANTIC("semantic", "语义切分"); | ||
| 42 | + | ||
| 43 | + /** | ||
| 44 | + * 策略代码 | ||
| 45 | + */ | ||
| 46 | + private final String code; | ||
| 47 | + | ||
| 48 | + /** | ||
| 49 | + * 策略名称 | ||
| 50 | + */ | ||
| 51 | + private final String name; | ||
| 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 | +} |
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/service/knowledge/AiKnowledgeSegmentServiceImpl.java
| @@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil; | @@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil; | ||
| 4 | import cn.hutool.core.collection.ListUtil; | 4 | import cn.hutool.core.collection.ListUtil; |
| 5 | import cn.hutool.core.util.ObjUtil; | 5 | import cn.hutool.core.util.ObjUtil; |
| 6 | import cn.hutool.core.util.StrUtil; | 6 | import cn.hutool.core.util.StrUtil; |
| 7 | + | ||
| 7 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | 8 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 8 | import cn.iocoder.yudao.framework.common.pojo.PageResult; | 9 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 9 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; | 10 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| @@ -15,8 +16,11 @@ import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDO; | @@ -15,8 +16,11 @@ import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDO; | ||
| 15 | import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDocumentDO; | 16 | import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDocumentDO; |
| 16 | import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeSegmentDO; | 17 | import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeSegmentDO; |
| 17 | import cn.iocoder.yudao.module.ai.dal.mysql.knowledge.AiKnowledgeSegmentMapper; | 18 | import cn.iocoder.yudao.module.ai.dal.mysql.knowledge.AiKnowledgeSegmentMapper; |
| 19 | +import cn.iocoder.yudao.module.ai.enums.AiDocumentSplitStrategyEnum; | ||
| 18 | import cn.iocoder.yudao.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchReqBO; | 20 | import cn.iocoder.yudao.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchReqBO; |
| 19 | import cn.iocoder.yudao.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchRespBO; | 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 | import cn.iocoder.yudao.module.ai.service.model.AiModelService; | 24 | import cn.iocoder.yudao.module.ai.service.model.AiModelService; |
| 21 | import com.alibaba.cloud.ai.dashscope.rerank.DashScopeRerankOptions; | 25 | import com.alibaba.cloud.ai.dashscope.rerank.DashScopeRerankOptions; |
| 22 | import com.alibaba.cloud.ai.model.RerankModel; | 26 | import com.alibaba.cloud.ai.model.RerankModel; |
| @@ -39,8 +43,7 @@ import java.util.*; | @@ -39,8 +43,7 @@ import java.util.*; | ||
| 39 | 43 | ||
| 40 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | 44 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 41 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; | 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 | import static org.springframework.ai.vectorstore.SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL; | 47 | import static org.springframework.ai.vectorstore.SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL; |
| 45 | 48 | ||
| 46 | /** | 49 | /** |
| @@ -95,16 +98,20 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService | @@ -95,16 +98,20 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService | ||
| 95 | AiKnowledgeDO knowledgeDO = knowledgeService.validateKnowledgeExists(documentDO.getKnowledgeId()); | 98 | AiKnowledgeDO knowledgeDO = knowledgeService.validateKnowledgeExists(documentDO.getKnowledgeId()); |
| 96 | VectorStore vectorStore = getVectorStoreById(knowledgeDO); | 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 | // 3.1 存储切片 | 105 | // 3.1 存储切片 |
| 102 | List<AiKnowledgeSegmentDO> segmentDOs = convertList(documentSegments, segment -> { | 106 | List<AiKnowledgeSegmentDO> segmentDOs = convertList(documentSegments, segment -> { |
| 103 | if (StrUtil.isEmpty(segment.getText())) { | 107 | if (StrUtil.isEmpty(segment.getText())) { |
| 104 | return null; | 108 | return null; |
| 105 | } | 109 | } |
| 106 | - return new AiKnowledgeSegmentDO().setKnowledgeId(documentDO.getKnowledgeId()).setDocumentId(documentId) | ||
| 107 | - .setContent(segment.getText()).setContentLength(segment.getText().length()) | 110 | + return new AiKnowledgeSegmentDO() |
| 111 | + .setKnowledgeId(documentDO.getKnowledgeId()) | ||
| 112 | + .setDocumentId(documentId) | ||
| 113 | + .setContent(segment.getText()) | ||
| 114 | + .setContentLength(segment.getText().length()) | ||
| 108 | .setVectorId(AiKnowledgeSegmentDO.VECTOR_ID_EMPTY) | 115 | .setVectorId(AiKnowledgeSegmentDO.VECTOR_ID_EMPTY) |
| 109 | .setTokens(tokenCountEstimator.estimate(segment.getText())) | 116 | .setTokens(tokenCountEstimator.estimate(segment.getText())) |
| 110 | .setStatus(CommonStatusEnum.ENABLE.getStatus()); | 117 | .setStatus(CommonStatusEnum.ENABLE.getStatus()); |
| @@ -295,10 +302,13 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService | @@ -295,10 +302,13 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService | ||
| 295 | // 1. 读取 URL 内容 | 302 | // 1. 读取 URL 内容 |
| 296 | String content = knowledgeDocumentService.readUrl(url); | 303 | String content = knowledgeDocumentService.readUrl(url); |
| 297 | 304 | ||
| 298 | - // 2. 文档切片 | ||
| 299 | - List<Document> documentSegments = splitContentByToken(content, segmentMaxTokens); | 305 | + // 2. 自动检测文档类型并选择策略 |
| 306 | + AiDocumentSplitStrategyEnum strategy = detectDocumentStrategy(content, url); | ||
| 307 | + | ||
| 308 | + // 3. 文档切片 | ||
| 309 | + List<Document> documentSegments = splitContentByStrategy(content, segmentMaxTokens, strategy, url); | ||
| 300 | 310 | ||
| 301 | - // 3. 转换为段落对象 | 311 | + // 4. 转换为段落对象 |
| 302 | return convertList(documentSegments, segment -> { | 312 | return convertList(documentSegments, segment -> { |
| 303 | if (StrUtil.isEmpty(segment.getText())) { | 313 | if (StrUtil.isEmpty(segment.getText())) { |
| 304 | return null; | 314 | return null; |
| @@ -333,11 +343,107 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService | @@ -333,11 +343,107 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService | ||
| 333 | return getVectorStoreById(knowledge); | 343 | return getVectorStoreById(knowledge); |
| 334 | } | 344 | } |
| 335 | 345 | ||
| 336 | - private static List<Document> splitContentByToken(String content, Integer segmentMaxTokens) { | ||
| 337 | - TextSplitter textSplitter = buildTokenTextSplitter(segmentMaxTokens); | 346 | + /** |
| 347 | + * 根据策略切分内容 | ||
| 348 | + * | ||
| 349 | + * @param content 文档内容 | ||
| 350 | + * @param segmentMaxTokens 分段的最大 Token 数 | ||
| 351 | + * @param strategy 切片策略 | ||
| 352 | + * @param url 文档 URL(用于自动检测文件类型) | ||
| 353 | + * @return 切片后的文档列表 | ||
| 354 | + */ | ||
| 355 | + private List<Document> splitContentByStrategy(String content, Integer segmentMaxTokens, | ||
| 356 | + AiDocumentSplitStrategyEnum strategy, String url) { | ||
| 357 | + // 自动检测策略 | ||
| 358 | + if (strategy == AiDocumentSplitStrategyEnum.AUTO) { | ||
| 359 | + strategy = detectDocumentStrategy(content, url); | ||
| 360 | + log.info("[splitContentByStrategy][自动检测到文档策略: {}]", strategy.getName()); | ||
| 361 | + } | ||
| 362 | + | ||
| 363 | + TextSplitter textSplitter; | ||
| 364 | + switch (strategy) { | ||
| 365 | + case MARKDOWN_QA: | ||
| 366 | + textSplitter = new MarkdownQaSplitter(segmentMaxTokens); | ||
| 367 | + break; | ||
| 368 | + case SEMANTIC: | ||
| 369 | + textSplitter = new SemanticTextSplitter(segmentMaxTokens); | ||
| 370 | + break; | ||
| 371 | + case PARAGRAPH: | ||
| 372 | + textSplitter = new SemanticTextSplitter(segmentMaxTokens, 0); // 段落切分,无重叠 | ||
| 373 | + break; | ||
| 374 | + case TOKEN: | ||
| 375 | + default: | ||
| 376 | + textSplitter = buildTokenTextSplitter(segmentMaxTokens); | ||
| 377 | + break; | ||
| 378 | + } | ||
| 379 | + | ||
| 338 | return textSplitter.apply(Collections.singletonList(new Document(content))); | 380 | return textSplitter.apply(Collections.singletonList(new Document(content))); |
| 339 | } | 381 | } |
| 340 | 382 | ||
| 383 | + /** | ||
| 384 | + * 自动检测文档类型并选择切片策略 | ||
| 385 | + * | ||
| 386 | + * @param content 文档内容 | ||
| 387 | + * @param url 文档 URL | ||
| 388 | + * @return 推荐的切片策略 | ||
| 389 | + */ | ||
| 390 | + private AiDocumentSplitStrategyEnum detectDocumentStrategy(String content, String url) { | ||
| 391 | + if (StrUtil.isEmpty(content)) { | ||
| 392 | + return AiDocumentSplitStrategyEnum.TOKEN; | ||
| 393 | + } | ||
| 394 | + | ||
| 395 | + // 1. 检测 Markdown QA 格式 | ||
| 396 | + if (isMarkdownQaFormat(content, url)) { | ||
| 397 | + return AiDocumentSplitStrategyEnum.MARKDOWN_QA; | ||
| 398 | + } | ||
| 399 | + | ||
| 400 | + // 2. 检测普通 Markdown 文档 | ||
| 401 | + if (isMarkdownDocument(url)) { | ||
| 402 | + return AiDocumentSplitStrategyEnum.SEMANTIC; | ||
| 403 | + } | ||
| 404 | + | ||
| 405 | + // 3. 默认使用语义切分(比 Token 切分更智能) | ||
| 406 | + return AiDocumentSplitStrategyEnum.SEMANTIC; | ||
| 407 | + } | ||
| 408 | + | ||
| 409 | + /** | ||
| 410 | + * 检测是否为 Markdown QA 格式 | ||
| 411 | + * 特征:包含多个二级标题(## )且标题后紧跟答案内容 | ||
| 412 | + */ | ||
| 413 | + private boolean isMarkdownQaFormat(String content, String url) { | ||
| 414 | + // 文件扩展名判断 | ||
| 415 | + if (StrUtil.isNotEmpty(url) && !url.toLowerCase().endsWith(".md")) { | ||
| 416 | + return false; | ||
| 417 | + } | ||
| 418 | + | ||
| 419 | + // 统计二级标题数量 | ||
| 420 | + long h2Count = content.lines() | ||
| 421 | + .filter(line -> line.trim().startsWith("## ")) | ||
| 422 | + .count(); | ||
| 423 | + | ||
| 424 | + // 至少包含 2 个二级标题才认为是 QA 格式 | ||
| 425 | + if (h2Count < 2) { | ||
| 426 | + return false; | ||
| 427 | + } | ||
| 428 | + | ||
| 429 | + // 检查标题占比(QA 文档标题行数相对较多) | ||
| 430 | + long totalLines = content.lines().count(); | ||
| 431 | + double h2Ratio = (double) h2Count / totalLines; | ||
| 432 | + | ||
| 433 | + // 如果二级标题占比超过 10%,认为是 QA 格式 | ||
| 434 | + return h2Ratio > 0.1; | ||
| 435 | + } | ||
| 436 | + | ||
| 437 | + /** | ||
| 438 | + * 检测是否为 Markdown 文档 | ||
| 439 | + */ | ||
| 440 | + private boolean isMarkdownDocument(String url) { | ||
| 441 | + return StrUtil.isNotEmpty(url) && url.toLowerCase().endsWith(".md"); | ||
| 442 | + } | ||
| 443 | + | ||
| 444 | + /** | ||
| 445 | + * 构建基于 Token 的文本切片器(原有逻辑保留) | ||
| 446 | + */ | ||
| 341 | private static TextSplitter buildTokenTextSplitter(Integer segmentMaxTokens) { | 447 | private static TextSplitter buildTokenTextSplitter(Integer segmentMaxTokens) { |
| 342 | return TokenTextSplitter.builder() | 448 | return TokenTextSplitter.builder() |
| 343 | .withChunkSize(segmentMaxTokens) | 449 | .withChunkSize(segmentMaxTokens) |
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/service/knowledge/splitter/MarkdownQaSplitter.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.ai.service.knowledge.splitter; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.util.StrUtil; | ||
| 4 | +import lombok.extern.slf4j.Slf4j; | ||
| 5 | +import org.springframework.ai.transformer.splitter.TextSplitter; | ||
| 6 | + | ||
| 7 | +import java.util.ArrayList; | ||
| 8 | +import java.util.Collections; | ||
| 9 | +import java.util.List; | ||
| 10 | +import java.util.regex.Matcher; | ||
| 11 | +import java.util.regex.Pattern; | ||
| 12 | + | ||
| 13 | +/** | ||
| 14 | + * Markdown QA 格式专用切片器 | ||
| 15 | + * | ||
| 16 | + * <p>功能特点: | ||
| 17 | + * <ul> | ||
| 18 | + * <li>识别二级标题(## )作为问题标记</li> | ||
| 19 | + * <li>短 QA 对保持完整(不超过 Token 限制)</li> | ||
| 20 | + * <li>长答案智能切分,每个片段保留完整问题作为上下文</li> | ||
| 21 | + * <li>支持自定义 Token 估算器</li> | ||
| 22 | + * </ul> | ||
| 23 | + * | ||
| 24 | + * @author runzhen | ||
| 25 | + */ | ||
| 26 | +@Slf4j | ||
| 27 | +public class MarkdownQaSplitter extends TextSplitter { | ||
| 28 | + | ||
| 29 | + /** | ||
| 30 | + * 二级标题正则:匹配 "## " 开头的行 | ||
| 31 | + */ | ||
| 32 | + private static final Pattern H2_PATTERN = Pattern.compile("^##\\s+(.+)$", Pattern.MULTILINE); | ||
| 33 | + | ||
| 34 | + /** | ||
| 35 | + * 段落分隔符:双换行 | ||
| 36 | + */ | ||
| 37 | + private static final String PARAGRAPH_SEPARATOR = "\n\n"; | ||
| 38 | + | ||
| 39 | + /** | ||
| 40 | + * 句子分隔符 | ||
| 41 | + */ | ||
| 42 | + private static final Pattern SENTENCE_PATTERN = Pattern.compile("[。!?.!?]\\s*"); | ||
| 43 | + | ||
| 44 | + /** | ||
| 45 | + * 分段的最大 Token 数 | ||
| 46 | + */ | ||
| 47 | + private final int chunkSize; | ||
| 48 | + | ||
| 49 | + /** | ||
| 50 | + * Token 估算器(简单实现:中文按字符数,英文按单词数的 1.3 倍) | ||
| 51 | + */ | ||
| 52 | + private final TokenEstimator tokenEstimator; | ||
| 53 | + | ||
| 54 | + public MarkdownQaSplitter(int chunkSize) { | ||
| 55 | + this.chunkSize = chunkSize; | ||
| 56 | + this.tokenEstimator = new SimpleTokenEstimator(); | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + @Override | ||
| 60 | + protected List<String> splitText(String text) { | ||
| 61 | + if (StrUtil.isEmpty(text)) { | ||
| 62 | + return Collections.emptyList(); | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + List<String> result = new ArrayList<>(); | ||
| 66 | + | ||
| 67 | + // 解析 QA 对 | ||
| 68 | + List<QaPair> qaPairs = parseQaPairs(text); | ||
| 69 | + | ||
| 70 | + if (qaPairs.isEmpty()) { | ||
| 71 | + // 如果没有识别到 QA 格式,按段落切分 | ||
| 72 | + return fallbackSplit(text); | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + // 处理每个 QA 对 | ||
| 76 | + for (QaPair qaPair : qaPairs) { | ||
| 77 | + result.addAll(splitQaPair(qaPair)); | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + return result; | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + /** | ||
| 84 | + * 解析 Markdown QA 对 | ||
| 85 | + */ | ||
| 86 | + private List<QaPair> parseQaPairs(String content) { | ||
| 87 | + List<QaPair> qaPairs = new ArrayList<>(); | ||
| 88 | + Matcher matcher = H2_PATTERN.matcher(content); | ||
| 89 | + | ||
| 90 | + List<Integer> headingPositions = new ArrayList<>(); | ||
| 91 | + List<String> questions = new ArrayList<>(); | ||
| 92 | + | ||
| 93 | + // 找到所有二级标题位置 | ||
| 94 | + while (matcher.find()) { | ||
| 95 | + headingPositions.add(matcher.start()); | ||
| 96 | + questions.add(matcher.group(1).trim()); | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + if (headingPositions.isEmpty()) { | ||
| 100 | + return qaPairs; | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + // 提取每个 QA 对 | ||
| 104 | + for (int i = 0; i < headingPositions.size(); i++) { | ||
| 105 | + int start = headingPositions.get(i); | ||
| 106 | + int end = (i + 1 < headingPositions.size()) | ||
| 107 | + ? headingPositions.get(i + 1) | ||
| 108 | + : content.length(); | ||
| 109 | + | ||
| 110 | + String qaText = content.substring(start, end).trim(); | ||
| 111 | + String question = questions.get(i); | ||
| 112 | + | ||
| 113 | + // 提取答案部分(去掉问题标题) | ||
| 114 | + String answer = qaText.substring(qaText.indexOf('\n') + 1).trim(); | ||
| 115 | + | ||
| 116 | + qaPairs.add(new QaPair(question, answer, qaText)); | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + return qaPairs; | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + /** | ||
| 123 | + * 切分单个 QA 对 | ||
| 124 | + */ | ||
| 125 | + private List<String> splitQaPair(QaPair qaPair) { | ||
| 126 | + List<String> chunks = new ArrayList<>(); | ||
| 127 | + | ||
| 128 | + String fullQa = qaPair.fullText; | ||
| 129 | + int qaTokens = tokenEstimator.estimate(fullQa); | ||
| 130 | + | ||
| 131 | + // 如果整个 QA 对不超过限制,保持完整 | ||
| 132 | + if (qaTokens <= chunkSize) { | ||
| 133 | + chunks.add(fullQa); | ||
| 134 | + return chunks; | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + // 长答案需要切分 | ||
| 138 | + log.debug("QA 对超过 Token 限制 ({} > {}),开始智能切分: {}", | ||
| 139 | + qaTokens, chunkSize, qaPair.question); | ||
| 140 | + | ||
| 141 | + List<String> answerChunks = splitLongAnswer(qaPair.answer, qaPair.question); | ||
| 142 | + | ||
| 143 | + for (String answerChunk : answerChunks) { | ||
| 144 | + // 每个片段都包含完整问题 | ||
| 145 | + String chunkText = "## " + qaPair.question + "\n" + answerChunk; | ||
| 146 | + chunks.add(chunkText); | ||
| 147 | + } | ||
| 148 | + | ||
| 149 | + return chunks; | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + /** | ||
| 153 | + * 切分长答案 | ||
| 154 | + */ | ||
| 155 | + private List<String> splitLongAnswer(String answer, String question) { | ||
| 156 | + List<String> chunks = new ArrayList<>(); | ||
| 157 | + | ||
| 158 | + // 预留问题的 Token 空间 | ||
| 159 | + String questionHeader = "## " + question + "\n"; | ||
| 160 | + int questionTokens = tokenEstimator.estimate(questionHeader); | ||
| 161 | + int availableTokens = chunkSize - questionTokens - 10; // 预留 10 个 Token 的缓冲 | ||
| 162 | + | ||
| 163 | + // 先按段落切分 | ||
| 164 | + String[] paragraphs = answer.split(PARAGRAPH_SEPARATOR); | ||
| 165 | + | ||
| 166 | + StringBuilder currentChunk = new StringBuilder(); | ||
| 167 | + int currentTokens = 0; | ||
| 168 | + | ||
| 169 | + for (String paragraph : paragraphs) { | ||
| 170 | + if (StrUtil.isEmpty(paragraph)) { | ||
| 171 | + continue; | ||
| 172 | + } | ||
| 173 | + | ||
| 174 | + int paragraphTokens = tokenEstimator.estimate(paragraph); | ||
| 175 | + | ||
| 176 | + // 如果单个段落就超过限制,需要按句子切分 | ||
| 177 | + if (paragraphTokens > availableTokens) { | ||
| 178 | + // 先保存当前块 | ||
| 179 | + if (currentChunk.length() > 0) { | ||
| 180 | + chunks.add(currentChunk.toString().trim()); | ||
| 181 | + currentChunk = new StringBuilder(); | ||
| 182 | + currentTokens = 0; | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + // 按句子切分长段落 | ||
| 186 | + chunks.addAll(splitLongParagraph(paragraph, availableTokens)); | ||
| 187 | + continue; | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + // 如果加上这个段落会超过限制 | ||
| 191 | + if (currentTokens + paragraphTokens > availableTokens && currentChunk.length() > 0) { | ||
| 192 | + chunks.add(currentChunk.toString().trim()); | ||
| 193 | + currentChunk = new StringBuilder(); | ||
| 194 | + currentTokens = 0; | ||
| 195 | + } | ||
| 196 | + | ||
| 197 | + if (currentChunk.length() > 0) { | ||
| 198 | + currentChunk.append("\n\n"); | ||
| 199 | + } | ||
| 200 | + currentChunk.append(paragraph); | ||
| 201 | + currentTokens += paragraphTokens; | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | + // 添加最后一块 | ||
| 205 | + if (currentChunk.length() > 0) { | ||
| 206 | + chunks.add(currentChunk.toString().trim()); | ||
| 207 | + } | ||
| 208 | + | ||
| 209 | + return chunks.isEmpty() ? Collections.singletonList(answer) : chunks; | ||
| 210 | + } | ||
| 211 | + | ||
| 212 | + /** | ||
| 213 | + * 切分长段落(按句子) | ||
| 214 | + */ | ||
| 215 | + private List<String> splitLongParagraph(String paragraph, int availableTokens) { | ||
| 216 | + List<String> chunks = new ArrayList<>(); | ||
| 217 | + String[] sentences = SENTENCE_PATTERN.split(paragraph); | ||
| 218 | + | ||
| 219 | + StringBuilder currentChunk = new StringBuilder(); | ||
| 220 | + int currentTokens = 0; | ||
| 221 | + | ||
| 222 | + for (String sentence : sentences) { | ||
| 223 | + if (StrUtil.isEmpty(sentence)) { | ||
| 224 | + continue; | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + int sentenceTokens = tokenEstimator.estimate(sentence); | ||
| 228 | + | ||
| 229 | + // 如果单个句子就超过限制,强制切分 | ||
| 230 | + if (sentenceTokens > availableTokens) { | ||
| 231 | + if (currentChunk.length() > 0) { | ||
| 232 | + chunks.add(currentChunk.toString().trim()); | ||
| 233 | + currentChunk = new StringBuilder(); | ||
| 234 | + currentTokens = 0; | ||
| 235 | + } | ||
| 236 | + chunks.add(sentence.trim()); | ||
| 237 | + continue; | ||
| 238 | + } | ||
| 239 | + | ||
| 240 | + if (currentTokens + sentenceTokens > availableTokens && currentChunk.length() > 0) { | ||
| 241 | + chunks.add(currentChunk.toString().trim()); | ||
| 242 | + currentChunk = new StringBuilder(); | ||
| 243 | + currentTokens = 0; | ||
| 244 | + } | ||
| 245 | + | ||
| 246 | + currentChunk.append(sentence); | ||
| 247 | + currentTokens += sentenceTokens; | ||
| 248 | + } | ||
| 249 | + | ||
| 250 | + if (currentChunk.length() > 0) { | ||
| 251 | + chunks.add(currentChunk.toString().trim()); | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + return chunks.isEmpty() ? Collections.singletonList(paragraph) : chunks; | ||
| 255 | + } | ||
| 256 | + | ||
| 257 | + /** | ||
| 258 | + * 降级切分策略(当未识别到 QA 格式时) | ||
| 259 | + */ | ||
| 260 | + private List<String> fallbackSplit(String content) { | ||
| 261 | + List<String> chunks = new ArrayList<>(); | ||
| 262 | + String[] paragraphs = content.split(PARAGRAPH_SEPARATOR); | ||
| 263 | + | ||
| 264 | + StringBuilder currentChunk = new StringBuilder(); | ||
| 265 | + int currentTokens = 0; | ||
| 266 | + | ||
| 267 | + for (String paragraph : paragraphs) { | ||
| 268 | + if (StrUtil.isEmpty(paragraph)) { | ||
| 269 | + continue; | ||
| 270 | + } | ||
| 271 | + | ||
| 272 | + int paragraphTokens = tokenEstimator.estimate(paragraph); | ||
| 273 | + | ||
| 274 | + if (currentTokens + paragraphTokens > chunkSize && currentChunk.length() > 0) { | ||
| 275 | + chunks.add(currentChunk.toString().trim()); | ||
| 276 | + currentChunk = new StringBuilder(); | ||
| 277 | + currentTokens = 0; | ||
| 278 | + } | ||
| 279 | + | ||
| 280 | + if (currentChunk.length() > 0) { | ||
| 281 | + currentChunk.append("\n\n"); | ||
| 282 | + } | ||
| 283 | + currentChunk.append(paragraph); | ||
| 284 | + currentTokens += paragraphTokens; | ||
| 285 | + } | ||
| 286 | + | ||
| 287 | + if (currentChunk.length() > 0) { | ||
| 288 | + chunks.add(currentChunk.toString().trim()); | ||
| 289 | + } | ||
| 290 | + | ||
| 291 | + return chunks.isEmpty() ? Collections.singletonList(content) : chunks; | ||
| 292 | + } | ||
| 293 | + | ||
| 294 | + /** | ||
| 295 | + * QA 对数据结构 | ||
| 296 | + */ | ||
| 297 | + private static class QaPair { | ||
| 298 | + String question; | ||
| 299 | + String answer; | ||
| 300 | + String fullText; | ||
| 301 | + | ||
| 302 | + QaPair(String question, String answer, String fullText) { | ||
| 303 | + this.question = question; | ||
| 304 | + this.answer = answer; | ||
| 305 | + this.fullText = fullText; | ||
| 306 | + } | ||
| 307 | + } | ||
| 308 | + | ||
| 309 | + /** | ||
| 310 | + * Token 估算器接口 | ||
| 311 | + */ | ||
| 312 | + public interface TokenEstimator { | ||
| 313 | + int estimate(String text); | ||
| 314 | + } | ||
| 315 | + | ||
| 316 | + /** | ||
| 317 | + * 简单的 Token 估算器实现 | ||
| 318 | + * 中文:1 字符 ≈ 1 Token | ||
| 319 | + * 英文:1 单词 ≈ 1.3 Token | ||
| 320 | + */ | ||
| 321 | + private static class SimpleTokenEstimator implements TokenEstimator { | ||
| 322 | + @Override | ||
| 323 | + public int estimate(String text) { | ||
| 324 | + if (StrUtil.isEmpty(text)) { | ||
| 325 | + return 0; | ||
| 326 | + } | ||
| 327 | + | ||
| 328 | + int chineseChars = 0; | ||
| 329 | + int englishWords = 0; | ||
| 330 | + | ||
| 331 | + // 简单统计中英文 | ||
| 332 | + for (char c : text.toCharArray()) { | ||
| 333 | + if (c >= 0x4E00 && c <= 0x9FA5) { | ||
| 334 | + chineseChars++; | ||
| 335 | + } | ||
| 336 | + } | ||
| 337 | + | ||
| 338 | + // 英文单词估算 | ||
| 339 | + String[] words = text.split("\\s+"); | ||
| 340 | + for (String word : words) { | ||
| 341 | + if (word.matches(".*[a-zA-Z].*")) { | ||
| 342 | + englishWords++; | ||
| 343 | + } | ||
| 344 | + } | ||
| 345 | + | ||
| 346 | + return chineseChars + (int) (englishWords * 1.3); | ||
| 347 | + } | ||
| 348 | + } | ||
| 349 | +} |
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/service/knowledge/splitter/SemanticTextSplitter.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.ai.service.knowledge.splitter; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.util.StrUtil; | ||
| 4 | +import lombok.extern.slf4j.Slf4j; | ||
| 5 | +import org.springframework.ai.transformer.splitter.TextSplitter; | ||
| 6 | + | ||
| 7 | +import java.util.ArrayList; | ||
| 8 | +import java.util.Arrays; | ||
| 9 | +import java.util.Collections; | ||
| 10 | +import java.util.List; | ||
| 11 | +import java.util.regex.Pattern; | ||
| 12 | + | ||
| 13 | +/** | ||
| 14 | + * 语义化文本切片器 | ||
| 15 | + * | ||
| 16 | + * <p>功能特点: | ||
| 17 | + * <ul> | ||
| 18 | + * <li>优先在段落边界(双换行)处切分</li> | ||
| 19 | + * <li>其次在句子边界(句号、问号、感叹号)处切分</li> | ||
| 20 | + * <li>避免在句子中间截断,保持语义完整性</li> | ||
| 21 | + * <li>支持中英文标点符号识别</li> | ||
| 22 | + * </ul> | ||
| 23 | + * | ||
| 24 | + * @author runzhen | ||
| 25 | + */ | ||
| 26 | +@Slf4j | ||
| 27 | +public class SemanticTextSplitter extends TextSplitter { | ||
| 28 | + | ||
| 29 | + /** | ||
| 30 | + * 分段的最大 Token 数 | ||
| 31 | + */ | ||
| 32 | + private final int chunkSize; | ||
| 33 | + | ||
| 34 | + /** | ||
| 35 | + * 段落重叠大小(用于保持上下文连贯性) | ||
| 36 | + */ | ||
| 37 | + private final int chunkOverlap; | ||
| 38 | + | ||
| 39 | + /** | ||
| 40 | + * 段落分隔符(按优先级排序) | ||
| 41 | + */ | ||
| 42 | + private static final List<String> PARAGRAPH_SEPARATORS = Arrays.asList( | ||
| 43 | + "\n\n\n", // 三个换行 | ||
| 44 | + "\n\n", // 双换行 | ||
| 45 | + "\n" // 单换行 | ||
| 46 | + ); | ||
| 47 | + | ||
| 48 | + /** | ||
| 49 | + * 句子结束标记(中英文标点) | ||
| 50 | + */ | ||
| 51 | + private static final Pattern SENTENCE_END_PATTERN = Pattern.compile( | ||
| 52 | + "[。!?.!?]+[\\s\"'))】\\]]*" | ||
| 53 | + ); | ||
| 54 | + | ||
| 55 | + /** | ||
| 56 | + * Token 估算器 | ||
| 57 | + */ | ||
| 58 | + private final MarkdownQaSplitter.TokenEstimator tokenEstimator; | ||
| 59 | + | ||
| 60 | + public SemanticTextSplitter(int chunkSize, int chunkOverlap) { | ||
| 61 | + this.chunkSize = chunkSize; | ||
| 62 | + this.chunkOverlap = Math.min(chunkOverlap, chunkSize / 2); // 重叠不超过一半 | ||
| 63 | + this.tokenEstimator = new SimpleTokenEstimator(); | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + public SemanticTextSplitter(int chunkSize) { | ||
| 67 | + this(chunkSize, 50); // 默认重叠 50 个 Token | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + @Override | ||
| 71 | + protected List<String> splitText(String text) { | ||
| 72 | + if (StrUtil.isEmpty(text)) { | ||
| 73 | + return Collections.emptyList(); | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + return splitTextRecursive(text); | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + /** | ||
| 80 | + * 切分文本(递归策略) | ||
| 81 | + */ | ||
| 82 | + private List<String> splitTextRecursive(String text) { | ||
| 83 | + List<String> chunks = new ArrayList<>(); | ||
| 84 | + | ||
| 85 | + // 如果文本不超过限制,直接返回 | ||
| 86 | + int textTokens = tokenEstimator.estimate(text); | ||
| 87 | + if (textTokens <= chunkSize) { | ||
| 88 | + chunks.add(text.trim()); | ||
| 89 | + return chunks; | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + // 尝试按不同分隔符切分 | ||
| 93 | + List<String> splits = null; | ||
| 94 | + String usedSeparator = null; | ||
| 95 | + | ||
| 96 | + for (String separator : PARAGRAPH_SEPARATORS) { | ||
| 97 | + if (text.contains(separator)) { | ||
| 98 | + splits = Arrays.asList(text.split(Pattern.quote(separator))); | ||
| 99 | + usedSeparator = separator; | ||
| 100 | + break; | ||
| 101 | + } | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | + // 如果没有找到段落分隔符,按句子切分 | ||
| 105 | + if (splits == null || splits.size() == 1) { | ||
| 106 | + splits = splitBySentences(text); | ||
| 107 | + usedSeparator = ""; // 句子切分不需要分隔符 | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + // 合并小片段 | ||
| 111 | + chunks = mergeSplits(splits, usedSeparator); | ||
| 112 | + | ||
| 113 | + return chunks; | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + /** | ||
| 117 | + * 按句子切分 | ||
| 118 | + */ | ||
| 119 | + private List<String> splitBySentences(String text) { | ||
| 120 | + List<String> sentences = new ArrayList<>(); | ||
| 121 | + int lastEnd = 0; | ||
| 122 | + | ||
| 123 | + java.util.regex.Matcher matcher = SENTENCE_END_PATTERN.matcher(text); | ||
| 124 | + while (matcher.find()) { | ||
| 125 | + String sentence = text.substring(lastEnd, matcher.end()).trim(); | ||
| 126 | + if (StrUtil.isNotEmpty(sentence)) { | ||
| 127 | + sentences.add(sentence); | ||
| 128 | + } | ||
| 129 | + lastEnd = matcher.end(); | ||
| 130 | + } | ||
| 131 | + | ||
| 132 | + // 添加剩余部分 | ||
| 133 | + if (lastEnd < text.length()) { | ||
| 134 | + String remaining = text.substring(lastEnd).trim(); | ||
| 135 | + if (StrUtil.isNotEmpty(remaining)) { | ||
| 136 | + sentences.add(remaining); | ||
| 137 | + } | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + return sentences.isEmpty() ? Collections.singletonList(text) : sentences; | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + /** | ||
| 144 | + * 合并切分后的小片段 | ||
| 145 | + */ | ||
| 146 | + private List<String> mergeSplits(List<String> splits, String separator) { | ||
| 147 | + List<String> chunks = new ArrayList<>(); | ||
| 148 | + List<String> currentChunks = new ArrayList<>(); | ||
| 149 | + int currentLength = 0; | ||
| 150 | + | ||
| 151 | + for (String split : splits) { | ||
| 152 | + if (StrUtil.isEmpty(split)) { | ||
| 153 | + continue; | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + int splitTokens = tokenEstimator.estimate(split); | ||
| 157 | + | ||
| 158 | + // 如果单个片段就超过限制,进一步递归切分 | ||
| 159 | + if (splitTokens > chunkSize) { | ||
| 160 | + // 先保存当前累积的块 | ||
| 161 | + if (!currentChunks.isEmpty()) { | ||
| 162 | + String chunkText = String.join(separator, currentChunks); | ||
| 163 | + chunks.add(chunkText.trim()); | ||
| 164 | + currentChunks.clear(); | ||
| 165 | + currentLength = 0; | ||
| 166 | + } | ||
| 167 | + | ||
| 168 | + // 递归切分大片段 | ||
| 169 | + if (!separator.isEmpty()) { | ||
| 170 | + // 如果是段落分隔符,尝试按句子切分 | ||
| 171 | + chunks.addAll(splitTextRecursive(split)); | ||
| 172 | + } else { | ||
| 173 | + // 如果已经是句子级别,强制按字符切分 | ||
| 174 | + chunks.addAll(forceSplitLongText(split)); | ||
| 175 | + } | ||
| 176 | + continue; | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + // 计算加上分隔符的 Token 数 | ||
| 180 | + int separatorTokens = StrUtil.isEmpty(separator) ? 0 : tokenEstimator.estimate(separator); | ||
| 181 | + | ||
| 182 | + // 如果加上这个片段会超过限制 | ||
| 183 | + if (!currentChunks.isEmpty() && currentLength + splitTokens + separatorTokens > chunkSize) { | ||
| 184 | + // 保存当前块 | ||
| 185 | + String chunkText = String.join(separator, currentChunks); | ||
| 186 | + chunks.add(chunkText.trim()); | ||
| 187 | + | ||
| 188 | + // 处理重叠:保留最后几个片段 | ||
| 189 | + currentChunks = getOverlappingChunks(currentChunks, separator); | ||
| 190 | + currentLength = estimateTokens(currentChunks, separator); | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + currentChunks.add(split); | ||
| 194 | + currentLength += splitTokens + separatorTokens; | ||
| 195 | + } | ||
| 196 | + | ||
| 197 | + // 添加最后一块 | ||
| 198 | + if (!currentChunks.isEmpty()) { | ||
| 199 | + String chunkText = String.join(separator, currentChunks); | ||
| 200 | + chunks.add(chunkText.trim()); | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + return chunks; | ||
| 204 | + } | ||
| 205 | + | ||
| 206 | + /** | ||
| 207 | + * 获取重叠的片段(用于保持上下文) | ||
| 208 | + */ | ||
| 209 | + private List<String> getOverlappingChunks(List<String> chunks, String separator) { | ||
| 210 | + if (chunkOverlap == 0 || chunks.isEmpty()) { | ||
| 211 | + return new ArrayList<>(); | ||
| 212 | + } | ||
| 213 | + | ||
| 214 | + List<String> overlapping = new ArrayList<>(); | ||
| 215 | + int tokens = 0; | ||
| 216 | + | ||
| 217 | + // 从后往前取片段,直到达到重叠大小 | ||
| 218 | + for (int i = chunks.size() - 1; i >= 0; i--) { | ||
| 219 | + String chunk = chunks.get(i); | ||
| 220 | + int chunkTokens = tokenEstimator.estimate(chunk); | ||
| 221 | + | ||
| 222 | + if (tokens + chunkTokens > chunkOverlap) { | ||
| 223 | + break; | ||
| 224 | + } | ||
| 225 | + | ||
| 226 | + overlapping.add(0, chunk); | ||
| 227 | + tokens += chunkTokens + (StrUtil.isEmpty(separator) ? 0 : tokenEstimator.estimate(separator)); | ||
| 228 | + } | ||
| 229 | + | ||
| 230 | + return overlapping; | ||
| 231 | + } | ||
| 232 | + | ||
| 233 | + /** | ||
| 234 | + * 估算片段列表的总 Token 数 | ||
| 235 | + */ | ||
| 236 | + private int estimateTokens(List<String> chunks, String separator) { | ||
| 237 | + int total = 0; | ||
| 238 | + for (int i = 0; i < chunks.size(); i++) { | ||
| 239 | + total += tokenEstimator.estimate(chunks.get(i)); | ||
| 240 | + if (i < chunks.size() - 1 && StrUtil.isNotEmpty(separator)) { | ||
| 241 | + total += tokenEstimator.estimate(separator); | ||
| 242 | + } | ||
| 243 | + } | ||
| 244 | + return total; | ||
| 245 | + } | ||
| 246 | + | ||
| 247 | + /** | ||
| 248 | + * 强制切分长文本(当语义切分失败时) | ||
| 249 | + */ | ||
| 250 | + private List<String> forceSplitLongText(String text) { | ||
| 251 | + List<String> chunks = new ArrayList<>(); | ||
| 252 | + int charsPerChunk = (int) (chunkSize * 0.8); // 保守估计 | ||
| 253 | + | ||
| 254 | + for (int i = 0; i < text.length(); i += charsPerChunk) { | ||
| 255 | + int end = Math.min(i + charsPerChunk, text.length()); | ||
| 256 | + String chunk = text.substring(i, end); | ||
| 257 | + chunks.add(chunk.trim()); | ||
| 258 | + } | ||
| 259 | + | ||
| 260 | + log.warn("文本过长,已强制按字符切分,可能影响语义完整性"); | ||
| 261 | + return chunks; | ||
| 262 | + } | ||
| 263 | + | ||
| 264 | + /** | ||
| 265 | + * 简单的 Token 估算器实现 | ||
| 266 | + */ | ||
| 267 | + private static class SimpleTokenEstimator implements MarkdownQaSplitter.TokenEstimator { | ||
| 268 | + @Override | ||
| 269 | + public int estimate(String text) { | ||
| 270 | + if (StrUtil.isEmpty(text)) { | ||
| 271 | + return 0; | ||
| 272 | + } | ||
| 273 | + | ||
| 274 | + int chineseChars = 0; | ||
| 275 | + int englishWords = 0; | ||
| 276 | + | ||
| 277 | + for (char c : text.toCharArray()) { | ||
| 278 | + if (c >= 0x4E00 && c <= 0x9FA5) { | ||
| 279 | + chineseChars++; | ||
| 280 | + } | ||
| 281 | + } | ||
| 282 | + | ||
| 283 | + String[] words = text.split("\\s+"); | ||
| 284 | + for (String word : words) { | ||
| 285 | + if (word.matches(".*[a-zA-Z].*")) { | ||
| 286 | + englishWords++; | ||
| 287 | + } | ||
| 288 | + } | ||
| 289 | + | ||
| 290 | + return chineseChars + (int) (englishWords * 1.3); | ||
| 291 | + } | ||
| 292 | + } | ||
| 293 | +} |