数据预处理与分块策略:RAG效果一半取决于chunk怎么切,我踩了4个坑

2 阅读7分钟

第3篇搭RAG的时候,我把文档塞进向量库,查询结果死活不靠谱。当时以为是Embedding模型的问题,换了3个模型效果都差不多。

后来才发现:不是模型不行,是分块切烂了。

一份50页的产品文档,按512字符一切——表格被腰斩、代码被截断、章节标题和正文分家。向量库里的chunk全是残缺品,检索怎么可能准?

这一篇专门讲数据预处理和分块策略,这是RAG效果的地基。地基打歪了,上层的Embedding和检索再好也白搭。


先说结论

分块策略适用场景效果
固定长度切分日志、聊天记录简单但效果差
递归字符切分通用文档LangChain默认,够用
语义分块高质量文档效果最好,成本高
文档结构切分Markdown/HTML最精准,需预处理

Java人的理解:分块 ≈ 数据库建索引。你不会在一个没建索引的表上做查询优化,同样,你不会在没分好块的数据上做检索优化。


坑1:按固定长度切分,一刀下去信息全断

翻车现场

最直觉的做法:每512个字符切一段,overlap留50。

from langchain.text_splitter import CharacterTextSplitter
​
splitter = CharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=50,
    separator="\n"
)
chunks = splitter.split_text(doc)

结果:

Chunk 1: "...退货政策如下:| 类别 | 条件 | 处理时间 |"
Chunk 2: "| 未拆封 | 7天内 | 3个工作日 |"
Chunk 3: "| 已拆封 | 15天内 | 7个工作日 |"

表格被切成3段,单独看每段都是垃圾信息。 检索"已拆封商品退货要多久",只能命中Chunk 3,但Chunk 3没有"退货"关键词,根本检不出来。

代码也一样:

Chunk 1: "def process_order(order):\n    # 校验订单\n    if not order.id:"
Chunk 2: "        raise ValueError('订单ID不能为空')\n    # 查库存\n    inventory ="
Chunk 3: "    check_inventory(order.items)\n    # 计算价格\n    total = calc_"

一个完整函数被切成3段,每段都看不懂在干嘛。

正确做法:递归字符切分——按分隔符层级递归切

LangChain的RecursiveCharacterTextSplitter不是简单按字符数切,而是按分隔符优先级递归切:

from langchain.text_splitter import RecursiveCharacterTextSplitter
​
splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", "!", "?", ".", " ", ""]
)
chunks = splitter.split_text(doc)

切分逻辑: 先按双换行(段落)切 → 段落太长按单换行切 → 还太长按句号切 → 还太长按空格切 → 最后按字符切。

这样切出来的chunk,至少保证每段是完整的段落或句子,不会把一句话劈成两半。


坑2:Markdown/HTML的结构信息全丢了

翻车现场

产品文档是Markdown写的,结构很清晰:

## 退货政策
​
### 未拆封商品
7天内可退货,3个工作日处理。
​
### 已拆封商品
15天内可退货,7个工作日处理。

用递归字符切分后:

Chunk 1: "## 退货政策\n\n### 未拆封商品\n7天内可退货,3个工作日处理。"
Chunk 2: "### 已拆封商品\n15天内可退货,7个工作日处理。"

Chunk 2丢了"退货政策"这个大标题。 单独看"已拆封商品15天内可退货"——什么商品?不知道。用户问"退货政策",Chunk 2因为缺少上下文关键词,检索排名靠后。

正确做法:Markdown结构切分 + 上下文保留

from langchain.text_splitter import MarkdownHeaderTextSplitter
​
headers_to_split_on = [    ("#", "h1"),    ("##", "h2"),    ("###", "h3"),]
​
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
md_chunks = md_splitter.split_text(doc)
​
# 每个chunk自动携带层级信息
# Chunk 2的metadata: {"h1": "产品说明", "h2": "退货政策", "h3": "已拆封商品"}
# Chunk 2的内容: "15天内可退货,7个工作日处理。"

关键:metadata里有完整的标题路径。 检索的时候,把metadata拼到chunk内容前面:

def enrich_chunk(chunk):
    header_path = " > ".join(chunk.metadata.values())
    return f"[{header_path}]\n{chunk.page_content}"
​
enriched_chunks = [enrich_chunk(c) for c in md_chunks]

输出:

"[产品说明 > 退货政策 > 已拆封商品]\n15天内可退货,7个工作日处理。"

现在Chunk 2自带完整上下文,检索"退货政策"精准命中。

如果是HTML文档,用HTMLSectionSplitter,原理一样——按<h1>/<h2>/<section>等标签切分,保留DOM路径。


坑3:chunk_size随便设,大小全靠猜

翻车现场

chunk_size设多少?网上搜到的答案从256到2048都有,我直接用了512。

但实际测试发现:

文档类型chunk_size=256chunk_size=512chunk_size=1024
FAQ问答对效果好还行一个chunk塞了多个问题,混淆
产品说明文档信息不完整效果好还行
法律合同碎片化严重勉强效果好,保留完整条款

没有万能的chunk_size,不同文档类型需要不同的参数。

正确做法:按文档类型调参 + 实测对比

# FAQ类文档:短chunk + 小overlap
faq_splitter = RecursiveCharacterTextSplitter(
    chunk_size=256,
    chunk_overlap=20,
    separators=["\n\n", "\n", "。", ""]
)
​
# 长文档类:大chunk + 大overlap
doc_splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=100,
    separators=["\n\n", "\n", "。", "!", "?", ""]
)

实操建议:

  1. 先人工看5-10个chunk,内容是否完整可理解
  2. 用第19篇的RAGAS评估不同参数的检索效果
  3. 好的chunk = 一个chunk能独立回答一个问题

还有一个容易忽略的参数:chunk_overlap。overlap不是越大越好——太大会导致:

  • 存储量膨胀(重复内容多)
  • 检索时命中多个重复chunk,排名被稀释

经验值:overlap = chunk_size的10%-15%


坑4:只管切不管洗,脏数据进脏数据出

翻车现场

客户给了一堆PDF文档,直接提取文字塞进向量库。结果检索出来这种东西:

"5.2.3 退货政策 ......................15页\n\n注:本节内容2024年3月更新\n\n参见附录A"

目录页、页眉页脚、水印文字、格式残留——这些全是噪音,会让Embedding向量偏移,检索结果跑偏。

另一个坑:重复文档。同一个退货政策,在"用户手册"和"FAQ文档"里都有,切完chunk往向量库里一塞——同一问题命中两条高度相似的chunk,占掉了top-k的名额,把其他有价值的chunk挤掉了。

正确做法:清洗4步走

import re
​
def clean_text(text: str) -> str:
    # 第1步:去页码、页眉页脚
    text = re.sub(r'\d+页$', '', text, flags=re.MULTILINE)
    text = re.sub(r'^第\d+章.*$', '', text, flags=re.MULTILINE)
​
    # 第2步:去多余空白
    text = re.sub(r'\n{3,}', '\n\n', text)
    text = re.sub(r' +', ' ', text)
​
    # 第3步:去表格横线(Markdown表格残留)
    text = re.sub(r'^|[-:| ]+|$', '', text, flags=re.MULTILINE)
​
    # 第4步:去水印和版权声明
    text = re.sub(r'仅供内部使用', '', text)
    text = re.sub(r'版权所有.*保留一切权利', '', text)
​
    return text.strip()

去重: 在写入向量库之前,用MinHash或简单的文本哈希去重:

from hashlib import md5
​
seen = set()
unique_chunks = []
for chunk in all_chunks:
    content_hash = md5(chunk.page_content.encode()).hexdigest()
    if content_hash not in seen:
        seen.add(content_hash)
        unique_chunks.append(chunk)

如果用的是Chroma或Qdrant,还可以在写入时做相似度去重:新chunk和已有chunk的余弦相似度>0.95就跳过。


完整的分块流水线

把上面4个坑的解法串起来,一个靠谱的分块流水线长这样:

原始文档
  ↓
[1] 清洗:去页码/页眉/水印/空白/格式残留
  ↓
[2] 去重:MD5去重 + 相似度去重
  ↓
[3] 结构化切分:Markdown/HTML按标题层级切
  ↓  (纯文本用递归字符切分)
[4] 上下文增强:把标题路径拼到chunk内容
  ↓
[5] 写入向量库:附带metadata(来源/页码/标题路径)

代码示例:

from langchain.text_splitter import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter
from langchain.schema import Document
from hashlib import md5
​
def process_documents(raw_docs: list[Document]) -> list[Document]:
    """完整的文档处理流水线"""
    processed = []
​
    for doc in raw_docs:
        # Step 1: 清洗
        clean_content = clean_text(doc.page_content)
        if len(clean_content) < 20:
            continue
​
        # Step 2+3: 结构化切分 or 递归切分
        if doc.metadata.get("format") == "markdown":
            chunks = markdown_split(clean_content, doc.metadata)
        else:
            chunks = recursive_split(clean_content, doc.metadata)
​
        # Step 4: 上下文增强
        chunks = [enrich_chunk(c) for c in chunks]
​
        processed.extend(chunks)
​
    # Step 2: 去重
    processed = deduplicate(processed)
​
    return processed
​
def markdown_split(content: str, metadata: dict) -> list[Document]:
    md_splitter = MarkdownHeaderTextSplitter(
        headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")]
    )
    chunks = md_splitter.split_text(content)
    for chunk in chunks:
        chunk.metadata.update(metadata)
    return chunks
​
def recursive_split(content: str, metadata: dict) -> list[Document]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=512, chunk_overlap=50,
        separators=["\n\n", "\n", "。", "!", "?", ".", " ", ""]
    )
    texts = splitter.split_text(content)
    return [Document(page_content=t, metadata=metadata) for t in texts]
​
def enrich_chunk(chunk: Document) -> Document:
    headers = chunk.metadata.get("h1", "")
    if h2 := chunk.metadata.get("h2"):
        headers += f" > {h2}"
    if h3 := chunk.metadata.get("h3"):
        headers += f" > {h3}"
    if headers:
        chunk.page_content = f"[{headers}]\n{chunk.page_content}"
    return chunk
​
def deduplicate(chunks: list[Document]) -> list[Document]:
    seen = set()
    unique = []
    for chunk in chunks:
        h = md5(chunk.page_content.encode()).hexdigest()
        if h not in seen:
            seen.add(h)
            unique.append(chunk)
    return unique

回头看

坑一句话总结
按固定长度切一刀切把信息腰斩,用递归字符切分按语义边界切
结构信息丢了Markdown/HTML按标题层级切,metadata保留标题路径
chunk_size随便设按文档类型调参,overlap=chunk_size的10%-15%
只切不洗清洗4步:去噪音→去空白→去格式→去重

RAG效果不好,先别急着换模型,先看你的chunk切得对不对。 就像SQL查询慢,先看索引建没建,别上来就换数据库。