第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=256 | chunk_size=512 | chunk_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", "。", "!", "?", ""]
)
实操建议:
- 先人工看5-10个chunk,内容是否完整可理解
- 用第19篇的RAGAS评估不同参数的检索效果
- 好的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查询慢,先看索引建没建,别上来就换数据库。