4、分块策略优化:固定大小分块、语义分块、递归分块、滑动窗口分块
分块策略,说白了就是决定「一段文本切多细」以及「怎么切」。很多人觉得这步随便选个大小就行,其实不然。我见过太多项目,召回率上不去,查到最后发现是分块策略没选对。
你想想看,LLM 的上下文窗口是有限的。块太大,细节被淹没;块太小,语义又断了。这中间的平衡,就是我们要聊的核心。
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 根本看不懂。
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
我个人习惯在知识库类项目里用语义分块。比如技术文档,每个小节讲一个独立概念,语义边界非常清晰。但代价是计算量大,不适合实时场景。
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 策略选择与组合
实际项目中,我很少只用一种策略。更多时候是组合使用。比如:
- 先递归分块,再滑动窗口:先用递归保留段落结构,再对长段落做滑动窗口,保证上下文不丢。
- 语义分块 + 固定大小兜底:优先用语义分块,如果某段语义边界不明显,回退到固定大小。
下面这张图展示了四种策略的核心逻辑对比,方便你快速理解差异。
4.6 参数调优实战
不管你选哪种策略,有几个参数是绕不开的:
- 块大小(Chunk Size):通常 256-1024 。太小语义不完整,太大检索精度下降。
- 重叠大小(Overlap):建议 10%-20%。太小没效果,太大浪费存储。
- 分隔符列表:递归分块的核心。根据文档格式自定义。
最后说一句,分块策略没有银弹。我见过用固定分块跑得很好的项目,也见过语义分块翻车的案例。关键是理解你的数据,然后选最合适的策略。嗯,动手试试吧,参数调多了就有感觉了。