Commit 4af529cb9746b4f12c8a9162c793d3cfe8ba3a81

Authored by 芋道源码
Committed by Gitee
2 parents 4576801e 6bada543

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

Merge pull request !1468 from shixiaohe/master-jdk17
... ... @@ -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/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentPageReqVO.java
... ... @@ -11,7 +11,7 @@ import lombok.Data;
11 11 public class AiKnowledgeSegmentPageReqVO extends PageParam {
12 12  
13 13 @Schema(description = "文档编号", example = "1")
14   - private Integer documentId;
  14 + private Long documentId;
15 15  
16 16 @Schema(description = "分段内容关键字", example = "Java 开发")
17 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 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,8 +16,11 @@ import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDO;
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,8 +43,7 @@ import java.util.*;
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,16 +98,20 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
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 -> {
103 107 if (StrUtil.isEmpty(segment.getText())) {
104 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 115 .setVectorId(AiKnowledgeSegmentDO.VECTOR_ID_EMPTY)
109 116 .setTokens(tokenCountEstimator.estimate(segment.getText()))
110 117 .setStatus(CommonStatusEnum.ENABLE.getStatus());
... ... @@ -295,10 +302,13 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
295 302 // 1. 读取 URL 内容
296 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 312 return convertList(documentSegments, segment -> {
303 313 if (StrUtil.isEmpty(segment.getText())) {
304 314 return null;
... ... @@ -333,11 +343,107 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
333 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 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 447 private static TextSplitter buildTokenTextSplitter(Integer segmentMaxTokens) {
342 448 return TokenTextSplitter.builder()
343 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 +}
... ...