文本分块策略与预处理

一、引言

小说知识库的质量,很大程度上取决于文本分块的质量。如果分块太大,单个块会包含过多不相关信息,检索精度下降;如果分块太小,又可能破坏语义完整性,导致检索结果无法支撑有效的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应用的效果。通过持续优化分块策略,结合语义边界识别、重叠分块、分层设计等技术,可以不断提升知识库的质量,为用户提供更准确、更相关的知识检索服务。

相关推荐
三毛的二哥2 小时前
BEV:感知抖动问题及解决办法
人工智能·算法·计算机视觉
AI科技星2 小时前
宇宙终极几何:莫比乌斯光速螺旋统一理论-精细结构常数α本源结构
算法·机器学习·数学建模·数据挖掘·量子计算
Via_Neo2 小时前
区间dp算法
开发语言·javascript·算法
amcomputer2 小时前
简单总结拉格朗日乘数法
算法
hi_ro_a2 小时前
C++ 手撕 STL 底层:红黑树封装 mymap/myset
数据结构·c++·算法
tankeven2 小时前
贪心算法(Greedy Algorithm)详解:从理论到C++实践
c++·算法
Hesionberger2 小时前
LeetCode72.编辑距离(多维动态规划)
java·开发语言·c++·python·算法
lwf0061642 小时前
逻辑回归学习笔记-梯度下降求解回归方程
算法·机器学习·逻辑回归
人道领域2 小时前
【LeetCode刷题日记】1047:双栈法与双指针法巧妙消除相邻重复字符
java·算法·leetcode·职场和发展