文本分块策略与预处理
一、引言
小说知识库的质量,很大程度上取决于文本分块的质量。如果分块太大,单个块会包含过多不相关信息,检索精度下降;如果分块太小,又可能破坏语义完整性,导致检索结果无法支撑有效的RAG应用。在长文本的小说场景中,找到合适的分块策略尤为关键。
StoryVerse的文本处理流程经历了多轮迭代,从最初的简单按字符切分,到后来基于段落的语义保留策略,每个设计决策都经过仔细权衡。本文将从多个维度剖析这些设计思路。
二、文本预处理流程
2.1 文件读取与基础清洗
当用户上传小说文件后,系统首先将其读取为纯文本,并进行初步清洗。在实际处理中,需要考虑各种编码问题、换行符差异、特殊字符等。虽然当前实现相对简洁,但其中蕴含着重要设计考虑。
文本清洗有几个基本目标:一是统一换行符格式,将Windows的\r\n和老式Mac的\r统一为\n;二是移除无效的控制字符,保留有意义的文本内容;三是处理BOM(Byte Order Mark)标记,避免解析问题。这些虽然是细节工作,但对后续处理影响重大。
2.2 文本长度限制策略
系统对处理文本进行长度限制,这是一个重要的权衡:
private static final int MAX_PROCESSING_CHARS = 20000; private String limitProcessingText(String fullText) { if (fullText == null) { return ""; } if (fullText.length() <= MAX_PROCESSING_CHARS) { return fullText; } return fullText.substring(0, MAX_PROCESSING_CHARS); }限制为20000字符的设计考虑了多重因素:首先,LLM的上下文窗口有限,过长的文本会导致处理成本过高或超时;其次,大多数小说的开篇部分已经足够引入主要角色和世界观设定,这对MVP阶段的角色扮演功能已经够用;最后,这也为后续的渐进式处理预留了空间。
在更完善的版本中,可以考虑先处理前20000字符建立基础知识库,然后在后台继续处理后续内容,逐步丰富知识库。这种策略平衡了即时可用性和完整性。
三、分块策略实现
3.1 基于段落的分块算法
核心的分块逻辑围绕着保持段落完整性设计:
private static final int MAX_CHUNK_CHARS = 1200; private List<NovelKnowledgeClient.PlotChunk> buildPlotChunks(String fullText) { List<NovelKnowledgeClient.PlotChunk> chunks = new ArrayList<>(); // 按段落分割,过滤空行 List<String> paragraphs = fullText.lines() .map(String::trim) .filter(line -> !line.isBlank()) .toList(); if (paragraphs.isEmpty()) { throw new BusinessException("解析后的小说文本为空"); } // 合并段落形成块 StringBuilder buffer = new StringBuilder(); int chunkIndex = 0; for (String paragraph : paragraphs) { // 避免截断段落 if (!buffer.isEmpty() && buffer.length() + paragraph.length() + 1 > MAX_CHUNK_CHARS) { chunks.add(new NovelKnowledgeClient.PlotChunk(buffer.toString(), chunkIndex++)); buffer.setLength(0); } if (!buffer.isEmpty()) { buffer.append('\n'); } buffer.append(paragraph); } if (!buffer.isEmpty()) { chunks.add(new NovelKnowledgeClient.PlotChunk(buffer.toString(), chunkIndex)); } return chunks; }这个算法有几个精妙之处。首先,它按段落作为基本单位,确保单个段落不会被拆分到两个不同的块中。段落通常表达一个完整的意思,保持段落完整性对语义理解至关重要。
其次,分块决策是在添加新段落前进行的:if (!buffer.isEmpty() && buffer.length() + paragraph.length() + 1 > MAX_CHUNK_CHARS)。这个判断确保只有在添加当前段落会导致溢出时,才将缓冲区内容作为新块,然后开始新的缓冲区。这样可以尽可能地保持段落间的联系。
最后,为每个块分配chunkIndex,记录块在原文中的顺序。这个索引在后续检索中很有价值,因为有时最相关的信息可能不只是最相似的,还可能出现在相近的位置。
3.2 分块大小的经验选择
1200字符的限制是经过实验调整的结果。对于中文文本,1200字符大致相当于200-300个词语,这既足够包含一个完整的场景或对话,又不会让单个块包含太多不同的主题。
分块大小的选择需要考虑多种因素:Embedding模型的最大输入长度、向量检索的精度、下游LLM能处理的上下文窗口大小等。例如,如果使用的Embedding模型支持更大的输入,可能可以适当增加分块大小,以获得更好的语义连贯性。
值得注意的是,不同类型的文本可能需要不同的分块策略。对于对话密集的小说,可能需要更小的块来捕捉特定对话;对于描述性强的小说,可能需要稍大的块来保留环境和氛围描写。
四、知识提取中的文本处理
4.1 世界观提取中的文本选择
在构建世界观总结时,系统使用了小说全文但进行了截断:
MAX_WORLD_SUMMARY_CHARS = 12000 world_text = self._chat_completion( system_prompt=( "你是小说知识库整理助手。请基于小说正文提炼世界观总结。" "输出一段中文正文,不要标题,不要列表,不要解释。" ), user_prompt=( f"小说标题:{request_model.novel_title}\n\n" f"小说全文:\n{self._truncate_text(request_model.full_text, self.MAX_WORLD_SUMMARY_CHARS)}" ), )限制为12000字符的设计是因为世界观设定通常出现在小说的前半部分,特别是开篇章节。通过限制输入长度,既控制了LLM调用成本,又避免了后面内容对世界观提取的干扰。
截断方法本身也有设计:
def _truncate_text(self, text: str, max_chars: int) -> str: if len(text) <= max_chars: return text return text[:max_chars] + "\n...[truncated]"在截断处添加标记,让LLM知道文本被截断了,这可以微妙地影响模型行为,使其更谨慎地总结,避免做出超出给定文本范围的断言。
4.2 角色识别中的摘要策略
角色识别使用了不同的文本选择策略,它不仅使用全文摘要,还提供前几个剧情块的摘录:
MAX_CHARACTER_FULL_TEXT_CHARS = 6000 MAX_CHARACTER_CHUNKS = 8 MAX_CHARACTER_CHUNK_CHARS = 240 plot_excerpt = "\n\n".join( f"[chunk {item.chunk_index}] {self._truncate_text(item.text, self.MAX_CHARACTER_CHUNK_CHARS)}" for item in request_model.plot_chunks[: self.MAX_CHARACTER_CHUNKS] )这种双管齐下的策略很重要。全文摘要提供了整体背景,而剧情块摘录则提供了更具体的角色出场和互动的细节。每个剧情块被截断为240字符,这样可以在有限的上下文窗口中提供更多样化的片段,增加模型识别到所有主要角色的机会。
为每个块添加[chunk index]前缀也很有意义,这可以让模型知道文本的相对顺序,可能有助于理解角色的出场顺序和关系发展。
五、向量化前的文本规范化
5.1 向量生成中的文本处理
在生成向量之前,系统不需要复杂的预处理,因为Embedding模型通常能够处理原始文本。但在构建本地兜底方案的摘要时,系统进行了空白字符规范化:
private String truncateText(String text, int maxChars) { if (text == null || text.isBlank()) { return ""; } String normalized = text.replaceAll("\\s+", " ").trim(); if (normalized.length() <= maxChars) { return normalized; } return normalized.substring(0, maxChars) + "..."; }将所有空白字符序列替换为单个空格,确保文本的格式一致性。这对本地兜底很重要,因为它们直接向用户展示,格式整齐度会影响用户体验。
5.2 稳定ID生成中的文本处理
另一个重要的文本处理是用于生成稳定向量ID的slugify方法:
def _slugify(self, value: str) -> str: normalized = unicodedata.normalize("NFKC", value).strip().lower() slug = re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "-", normalized) return slug.strip("-") or "unknown"这里使用了unicodedata.normalize("NFKC")进行Unicode归一化,这确保了看起来相同但编码不同的字符(如全角和半角字符)能生成相同的ID。将文本转为小写,确保不区分大小写的一致性。
正则表达式[^0-9a-z\u4e00-\u9fff]+只允许数字、小写字母和中文字符,其他所有字符都被替换为连字符。这种设计既保持了足够的可识别性,又避免了特殊字符导致的问题。
六、分块质量的优化方向
6.1 基于语义边界的分块
当前基于段落和长度的策略虽然有效,但仍有改进空间。更智能的分块策略可以尝试识别文本中的自然语义边界,比如章节划分、场景转换、视角切换等,在这些边界处切分,而不是仅仅依赖长度。
例如,可以检测章节标题、明显的场景转换指示(如空行分隔、时间地点标记)等,优先在这些地方进行切分,使每个块更可能包含一个完整的语义单元。
6.2 重叠分块策略
在检索中,相关信息可能出现在分块边界附近。重叠分块(Overlapping Chunks)策略让相邻块有一定重叠,这样即使切分不完美,重要信息也更可能完整地出现在至少一个块中。
典型的重叠大小可以是分块大小的10-20%。当然,这会增加存储和计算成本,需要在质量和效率之间进行权衡。
6.3 分层分块设计
另一个方向是分层分块:同时维护几个不同粒度的分块方案,如短段落级、场景级、章级。在检索时,可以根据查询性质选择合适粒度的块进行搜索,或者融合不同粒度的检索结果。这种设计提供了更大的灵活性。
七、总结
文本分块和预处理是构建高质量知识库的基石,其重要性不亚于Embedding模型选择或向量数据库设计。StoryVerse的实现虽然简洁,但每个决策都有其考虑:基于段落的分块策略保持了语义完整性,精心选择的长度限制平衡了多种需求,各种文本规范化处理确保了系统的鲁棒性。
分块工作看似平凡,实则直接影响检索质量,进而影响整个RAG应用的效果。通过持续优化分块策略,结合语义边界识别、重叠分块、分层设计等技术,可以不断提升知识库的质量,为用户提供更准确、更相关的知识检索服务。
