RAG時,檢索效果的優劣,和文本的分塊的情況有很大關系。
SpringAI中通過TokenTextSplitter對文本分塊。本文對SpringAI提供的TokenTextSplitter源碼進行了分析,并給出一些自己的想法,歡迎大家互相探討。
查看了TokenTextSplitter的源碼,其進行文本分塊的核心代碼如下:
protected List<String> doSplit(String text, int chunkSize) {if (text != null && !text.trim().isEmpty()) {// 將分割的內容轉為對應token的列表List<Integer> tokens = this.getEncodedTokens(text);List<String> chunks = new ArrayList();int num_chunks = 0;while(!tokens.isEmpty() && num_chunks < this.maxNumChunks) {// 根據token列表,按照chunkSize或者token列表長度的最小值進行截取List<Integer> chunk = tokens.subList(0, Math.min(chunkSize, tokens.size()));// 將token轉為字符串String chunkText = this.decodeTokens(chunk);if (chunkText.trim().isEmpty()) {tokens = tokens.subList(chunk.size(), tokens.size());} else {// 從文本最后開始,獲取英文的.!?和換行符的索引int lastPunctuation = Math.max(chunkText.lastIndexOf(46), Math.max(chunkText.lastIndexOf(63), Math.max(chunkText.lastIndexOf(33), chunkText.lastIndexOf(10))));// 如果索引值不是-1,并且索引大于分塊的最小的字符數,對分塊內容進行截取if (lastPunctuation != -1 && lastPunctuation > this.minChunkSizeChars) {chunkText = chunkText.substring(0, lastPunctuation + 1);}// 如果keepSeparator是false,將本文中的換行符替換為空格String chunkTextToAppend = this.keepSeparator ? chunkText.trim() : chunkText.replace(System.lineSeparator(), " ").trim();if (chunkTextToAppend.length() > this.minChunkLengthToEmbed) {// 將分塊內容添加到分塊列表中chunks.add(chunkTextToAppend);}// 對原來的token列表進行截取,用于排除已經分塊的內容tokens = tokens.subList(this.getEncodedTokens(chunkText).size(), tokens.size());++num_chunks;}}if (!tokens.isEmpty()) {String remaining_text = this.decodeTokens(tokens).replace(System.lineSeparator(), " ").trim();if (remaining_text.length() > this.minChunkLengthToEmbed) {chunks.add(remaining_text);}}return chunks;} else {return new ArrayList();}}
參數說明:?
chunkSize: 每個文本塊以 token 為單位的目標大小(默認值:800)。
minChunkSizeChars: 每個文本塊以字符為單位的最小大小(默認值:350)。
minChunkLengthToEmbed: 文本塊去除空白字符或者處理分隔符后,用于嵌入處理的文本的最小長度(默認值:5)。
maxNumChunks: 從文本生成的最大塊數(默認值:10000)。
keepSeparator: 是否在塊中保留分隔符(例如換行符)(默認值:true)。
TokenTextSplitter拆分文檔的邏輯:
1.使用 CL100K_BASE 編碼將輸入文本編碼為 token列表
2.根據 chunkSize 對編碼后的token列表進行截取分塊
3.對于分塊:
? ? ? ? (1)將token分塊再解碼為文本字符串
? ? ? ? (2)嘗試從后向前找到一個合適的截斷點(默認是英文的句號、問號、感嘆號或換行符)。
? ? ? ? (3)如果找到合適的截斷點,并且截斷點所在的index大于minChunkSizeChars,則將在該點截斷該塊
? ? ? ? (4)對分塊去除兩邊的空白字符,并根據 keepSeparator 設置,如果為false,則移除換行符
? ? ? ? (5)如果處理后的分塊長度大于 minChunkLengthToEmbed,則將其添加到分塊列表中
4.持續執行第2步和第3步,直到所有 token 都被處理完或達到 maxNumChunks
5.如果還有剩余的token沒有處理,并且剩余的token進行編碼和轉換處理后,長度大于 minChunkLengthToEmbed,則將其作為最終塊添加
源碼中,是根據英文的逗號,嘆號,問號和換行符進行文本的截取。這顯然不太符合中文文檔的語法習慣。為此,我們對源碼進行修改,增加分割符的列表,用戶可以根據文檔的中英文情況,自行設置分割符。自定義的分割類代碼如下:
package com.renr.springainew.controller;import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.Encoding;
import com.knuddels.jtokkit.api.EncodingRegistry;
import com.knuddels.jtokkit.api.EncodingType;
import com.knuddels.jtokkit.api.IntArrayList;
import org.springframework.ai.transformer.splitter.TextSplitter;
import org.springframework.util.Assert;import java.util.*;/*** @Classname MyTextSplit* @Description TODO* @Date 2025-07-26 9:46* @Created by 老任與碼*/
public class MyTextSplit extends TextSplitter {private static final int DEFAULT_CHUNK_SIZE = 800;private static final int MIN_CHUNK_SIZE_CHARS = 350;private static final int MIN_CHUNK_LENGTH_TO_EMBED = 5;private static final int MAX_NUM_CHUNKS = 10000;private static final boolean KEEP_SEPARATOR = true;private final EncodingRegistry registry;private final Encoding encoding;private final int chunkSize;private final int minChunkSizeChars;private final int minChunkLengthToEmbed;private final int maxNumChunks;private final boolean keepSeparator;private final List<String> splitList;public MyTextSplit() {this(800, 350, 5, 10000, true, Arrays.asList(".", "!", "?", "\n"));}public MyTextSplit(boolean keepSeparator) {this(800, 350, 5, 10000, keepSeparator, Arrays.asList(".", "!", "?", "\n"));}public MyTextSplit(int chunkSize, int minChunkSizeChars, int minChunkLengthToEmbed, int maxNumChunks, boolean keepSeparator, List<String> splitList) {this.registry = Encodings.newLazyEncodingRegistry();this.encoding = this.registry.getEncoding(EncodingType.CL100K_BASE);this.chunkSize = chunkSize;this.minChunkSizeChars = minChunkSizeChars;this.minChunkLengthToEmbed = minChunkLengthToEmbed;this.maxNumChunks = maxNumChunks;this.keepSeparator = keepSeparator;if (splitList == null || splitList.isEmpty()) {this.splitList = Arrays.asList(".", "!", "?", "\n");} else {this.splitList = splitList;}}protected List<String> splitText(String text) {return this.doSplit(text, this.chunkSize);}protected List<String> doSplit(String text, int chunkSize) {if (text != null && !text.trim().isEmpty()) {List<Integer> tokens = this.getEncodedTokens(text);List<String> chunks = new ArrayList();int num_chunks = 0;while (!tokens.isEmpty() && num_chunks < this.maxNumChunks) {List<Integer> chunk = tokens.subList(0, Math.min(chunkSize, tokens.size()));String chunkText = this.decodeTokens(chunk);if (chunkText.trim().isEmpty()) {tokens = tokens.subList(chunk.size(), tokens.size());} else {int lastPunctuation = splitList.stream().mapToInt(chunkText::lastIndexOf).max().orElse(-1);// 46 . 63 ? 33 ! 10換行// int lastPunctuation = Math.max(chunkText.lastIndexOf(46), Math.max(chunkText.lastIndexOf(63), Math.max(chunkText.lastIndexOf(33), chunkText.lastIndexOf(10))));if (lastPunctuation != -1 && lastPunctuation > this.minChunkSizeChars) {chunkText = chunkText.substring(0, lastPunctuation + 1);}String chunkTextToAppend = this.keepSeparator ? chunkText.trim() : chunkText.replace(System.lineSeparator(), " ").trim();if (chunkTextToAppend.length() > this.minChunkLengthToEmbed) {chunks.add(chunkTextToAppend);}tokens = tokens.subList(this.getEncodedTokens(chunkText).size(), tokens.size());++num_chunks;}}if (!tokens.isEmpty()) {String remaining_text = this.decodeTokens(tokens).replace(System.lineSeparator(), " ").trim();if (remaining_text.length() > this.minChunkLengthToEmbed) {chunks.add(remaining_text);}}return chunks;} else {return new ArrayList();}}private List<Integer> getEncodedTokens(String text) {Assert.notNull(text, "Text must not be null");return this.encoding.encode(text).boxed();}private String decodeTokens(List<Integer> tokens) {Assert.notNull(tokens, "Tokens must not be null");IntArrayList tokensIntArray = new IntArrayList(tokens.size());Objects.requireNonNull(tokensIntArray);tokens.forEach(tokensIntArray::add);return this.encoding.decode(tokensIntArray);}}
測試代碼:
public void init2() {// 讀取文本文件TextReader textReader = new TextReader(this.resource);// 元數據中增加文件名textReader.getCustomMetadata().put("filename", "醫院.txt");// 獲取Document對象,只有一個記錄List<Document> docList = textReader.read();// 指定分割符List<String> splitList = Arrays.asList("。", "!", "?", System.lineSeparator());MyTextSplit splitter = new MyTextSplit(300, 100, 5, 10000, true, splitList);List<Document> splitDocuments = splitter.apply(docList);System.out.println(splitDocuments);}
另外,根據源碼,minChunkSizeChars的值要小于chunkSize的值才有意義。
根據CL100K_BASE編碼,300長度的token轉為本文內容后,文本內容的長度在220-250之間(根據本例的中文文檔測試,實際存在誤差),轉換比例在70%到80%多,為了根據特定的字符進行分割,所以minChunkSize的值最好小于210。
根據源碼的邏輯,分割文本時,可能出現如果分隔符的索引小于minChunkSizeChars,就不會對文本進行分割,于是,就會出現句子被斷開的情況。
針對該現象,可以增加分割的字符種類;或者干脆將minChunkSizeChars設置為0(解決方案有點簡單粗暴哈O(∩_∩)O哈哈~);還可以根據分割后的內容,進行手動修改,然后再進行向量化處理。
該代碼存在的問題:
使用由于是先轉為token列表;再轉為字符串后,根據分割符進行截取;截取后轉為token,再根據token長度截取token列表,索引多次轉換后,使用CL100K_BASE編碼會存在一些中文數據的丟失或者亂碼情況。
經過測試,可以將編碼方式修改為O200K_BASE編碼。使用該編碼后,中文轉換的token列表長度小于文本本身的長度,所以分塊時,需要重置chunkSize和minChunkSizeChars的值。
this.encoding = this.registry.getEncoding(EncodingType.O200K_BASE);