4、分块策略优化:固定大小分块、语义分块、递归分块、滑动窗口分块

分块策略,说白了就是决定「一段文本切多细」以及「怎么切」。很多人觉得这步随便选个大小就行,其实不然。我见过太多项目,召回率上不去,查到最后发现是分块策略没选对。

你想想看,LLM 的上下文窗口是有限的。块太大,细节被淹没;块太小,语义又断了。这中间的平衡,就是我们要聊的核心。

核心观点:没有「最好」的分块策略,只有「最适合你数据」的策略。选对了,检索精度能提升 15%-30%。

4.1 固定大小分块(Fixed-Size Chunking)

这是最朴素的方法。按字符数或 数硬切,比如每 512 个 一刀。实现简单,跑起来飞快。

def fixed_size_chunk(text, chunk_size=512, overlap=50):
    tokens = tokenize(text)
    chunks = []
    start = 0
    while start < len(tokens):
        end = start + chunk_size
        chunk = tokens[start:end]
        chunks.append(detokenize(chunk))
        start = end - overlap  # 重叠区域
    return chunks

嗯,这里要注意。固定大小分块有个致命问题——它不懂语义。一刀下去,可能把一个完整的句子从中间劈开。我在项目中遇到过,检索出来的片段正好断在关键信息中间,LLM 根本看不懂。

适用场景:日志数据、格式统一的文档、对精度要求不高的快速原型。
避坑:我曾经在一个法律文档项目里用了固定分块,结果「被告」和「辩护人」被切到两个块里,检索出来的上下文全是错的。后来我加了至少 10%-20% 的重叠(overlap),情况才好转。

4.2 语义分块(Semantic Chunking)

语义分块就聪明多了。它先理解文本的语义边界,比如段落结束、话题转换的地方,再下刀。说白了,就是让 AI 自己决定「这里该切一刀」。

实现方式通常有两种:

  • 基于嵌入相似度:计算相邻句子的向量相似度,相似度骤降的地方就是分块点。
  • 基于 NLP 模型:用 BERT 或 判断话题边界,比如检测到「接下来我们讨论...」这种转折词。
def semantic_chunk(text, model, threshold=0.7):
    sentences = split_sentences(text)
    embeddings = model.encode(sentences)
    chunks = []
    current_chunk = [sentences[0]]
    
    for i in range(1, len(sentences)):
        sim = cosine_similarity(embeddings[i-1], embeddings[i])
        if sim < threshold:  # 语义变化大,切
            chunks.append(' '.join(current_chunk))
            current_chunk = [sentences[i]]
        else:
            current_chunk.append(sentences[i])
    
    if current_chunk:
        chunks.append(' '.join(current_chunk))
    return chunks

我个人习惯在知识库类项目里用语义分块。比如技术文档,每个小节讲一个独立概念,语义边界非常清晰。但代价是计算量大,不适合实时场景。

经验之谈:阈值设 0.6-0.8 之间比较稳妥。太低切得太碎,太高又切不开。我一般先用 0.7 跑一遍,看结果再微调。

4.3 递归分块(Recursive Chunking)

递归分块是 LangChain 里常用的策略。它的思路是:先用大粒度切(比如按段落),如果块太大,再递归地按更细粒度切(比如按句子、按 )。

为什么会这样设计?因为现实中的文档结构是分层的。一个章节包含多个段落,一个段落包含多个句子。递归分块尊重这种层级。

def recursive_chunk(text, separators=["\n\n", "\n", ".", " "], chunk_size=512):
    chunks = []
    _recursive_split(text, separators, chunk_size, chunks)
    return chunks

def _recursive_split(text, separators, chunk_size, result):
    if len(text) <= chunk_size:
        result.append(text)
        return
    
    for sep in separators:
        parts = text.split(sep)
        # 找到第一个能让块大小合适的切分点
        current = ""
        for part in parts:
            if len(current) + len(part) < chunk_size:
                current += part + sep
            else:
                if current:
                    result.append(current.strip())
                current = part + sep
        if current:
            result.append(current.strip())
        return

我记得有一次做企业内部知识库,文档格式五花八门。固定分块效果很差,语义分块又太慢。最后用了递归分块,配合自定义分隔符列表,效果立竿见影。

分隔符优先级建议:双换行(段落)→ 单换行 → 句号 → 逗号 → 空格。这样能最大程度保留语义完整性。

4.4 滑动窗口分块(Sliding Window Chunking)

滑动窗口分块,本质上是对固定分块的改进。它让相邻块之间有重叠区域,保证上下文不丢失。

你想想看,如果块之间完全没有重叠,那「张三的猫」和「猫的名字叫咪咪」可能被分到两个块里。检索时只拿到前半句,LLM 就不知道猫叫啥。

def sliding_window_chunk(text, window_size=512, stride=256):
    tokens = tokenize(text)
    chunks = []
    start = 0
    while start < len(tokens):
        end = min(start + window_size, len(tokens))
        chunk = tokens[start:end]
        chunks.append(detokenize(chunk))
        start += stride  # 步长小于窗口大小,产生重叠
        if end == len(tokens):
            break
    return chunks

滑动窗口的优点是实现简单,效果稳定。缺点是会产生大量冗余数据,存储和索引成本上升。

策略 优点 缺点 推荐场景
固定大小分块 简单、快速、可控 破坏语义、精度低 日志、快速原型
语义分块 语义完整、精度高 计算量大、延迟高 知识库、技术文档
递归分块 适应性强、结构保留好 实现复杂、参数多 格式多样的文档
滑动窗口分块 上下文连续、实现简单 数据冗余、存储成本高 长文本、对话历史

4.5 策略选择与组合

实际项目中,我很少只用一种策略。更多时候是组合使用。比如:

  • 先递归分块,再滑动窗口:先用递归保留段落结构,再对长段落做滑动窗口,保证上下文不丢。
  • 语义分块 + 固定大小兜底:优先用语义分块,如果某段语义边界不明显,回退到固定大小。
我的建议:别一上来就调参数。先拿 100 条样本,手动标注理想的分块点。然后对比不同策略的切分结果,选最接近人工标注的那个。

下面这张图展示了四种策略的核心逻辑对比,方便你快速理解差异。

四种分块策略对比 固定大小分块 块1 (512 ) 块2 (512 ) 块3 (512 ) 语义分块 段落1 (完整语义) 段落2 (完整语义) 段落3 (完整语义) 递归分块 章节 (大块) → 段落 (中块) → 句子 (小块) 滑动窗口分块 块1 块2 (重叠) 块3 (重叠) 选择建议 数据格式统一 → 固定分块 语义完整优先 → 语义分块 选择建议 格式多样 → 递归分块 上下文连续 → 滑动窗口

4.6 参数调优实战

不管你选哪种策略,有几个参数是绕不开的:

  • 块大小(Chunk Size):通常 256-1024 。太小语义不完整,太大检索精度下降。
  • 重叠大小(Overlap):建议 10%-20%。太小没效果,太大浪费存储。
  • 分隔符列表:递归分块的核心。根据文档格式自定义。
调参小技巧:先固定其他参数,只调一个。比如先确定块大小,再调重叠比例。我习惯用 A/B 测试,对比不同参数下的召回率,选最优组合。

最后说一句,分块策略没有银弹。我见过用固定分块跑得很好的项目,也见过语义分块翻车的案例。关键是理解你的数据,然后选最合适的策略。嗯,动手试试吧,参数调多了就有感觉了。