Chapter 02

文档处理与分块策略

高质量的文档处理是 RAG 系统的地基——垃圾进,垃圾出。掌握正确的分块策略才能让检索精准命中。

为什么文档处理至关重要

RAG 系统的质量上限由数据质量决定。一个优秀的 LLM 配上糟糕的文档处理,依然会给出混乱的答案。文档处理包括三个核心环节:加载(把各种格式的文件读成文本)、清洗(去除噪声、格式化)和分块(把长文档切成适合检索的小片段)。

分块不当的后果

分块过大:每个 chunk 包含太多不相关内容,导致 LLM 答案质量下降,也占用宝贵的 Context Window。分块过小:语义不完整,检索时找不到完整的答案片段。找到合适的中间值是核心挑战。

文档加载(Document Loading)

现实世界的文档格式五花八门:PDF、Word、网页、Markdown、数据库记录……每种格式都需要专门的加载器。

Document Loader
文档加载器,负责将各种格式的源文件转换为统一的文本表示。输出通常是包含 page_content(文本内容)和 metadata(元数据:文件名、页码、创建时间等)的对象。
Metadata
与文档内容关联的结构化信息,如来源文件名、页码、创建日期、作者等。在 RAG 中极为重要——可以用于过滤(只检索特定部门的文档)和答案溯源(告诉用户答案来自哪个文件第几页)。
OCR
光学字符识别(Optical Character Recognition),将扫描版 PDF 或图片中的文字转为可处理的文本。常用工具:Tesseract、Azure Form Recognizer、AWS Textract。

用 LangChain 加载各类文档

from langchain_community.document_loaders import (
    PyPDFLoader,          # PDF 文件
    Docx2txtLoader,       # Word .docx
    WebBaseLoader,        # 网页
    TextLoader,           # 纯文本 / Markdown
    CSVLoader,            # CSV 表格
    UnstructuredExcelLoader, # Excel
)
from langchain_core.documents import Document

# ── PDF 加载(按页分割)──────────────────
pdf_loader = PyPDFLoader("docs/annual_report.pdf")
pdf_pages = pdf_loader.load()
print(f"PDF 共 {len(pdf_pages)} 页")
print(pdf_pages[0].metadata)
# {'source': 'docs/annual_report.pdf', 'page': 0}

# ── Word 文档加载 ───────────────────────
docx_loader = Docx2txtLoader("docs/contract.docx")
docx_docs = docx_loader.load()  # 返回单个 Document

# ── 网页加载(带正文提取)──────────────
web_loader = WebBaseLoader(
    web_paths=["https://docs.qdrant.tech/concepts/collections/"],
    bs_kwargs={"features": "html.parser"}
)
web_docs = web_loader.load()

# ── 批量加载目录所有 PDF ─────────────────
from langchain_community.document_loaders import DirectoryLoader

dir_loader = DirectoryLoader(
    "./knowledge_base/",
    glob="**/*.pdf",
    loader_cls=PyPDFLoader,
    show_progress=True,
)
all_docs = dir_loader.load()
print(f"加载了 {len(all_docs)} 个文档块")

文档清洗:不容忽视的预处理步骤

import re
from langchain_core.documents import Document

def clean_document(doc: Document) -> Document:
    """清洗文档内容,去除噪声"""
    text = doc.page_content

    # 去除多余空白行(PDF 常见问题)
    text = re.sub(r'\n{3,}', '\n\n', text)

    # 去除页眉页脚常见模式(如页码)
    text = re.sub(r'^\d+\s*$', '', text, flags=re.MULTILINE)

    # 修复 PDF 中的连字符断行
    text = re.sub(r'(\w)-\n(\w)', r'\1\2', text)

    # 去除 HTML 标签(网页文档)
    text = re.sub(r'<[^>]+>', '', text)

    # 过滤过短的页(可能是目录页/空白页)
    if len(text.strip()) < 50:
        return None

    return Document(
        page_content=text.strip(),
        metadata=doc.metadata
    )

cleaned = [d for d in (clean_document(doc) for doc in all_docs) if d]

分块策略详解

文档分块(Chunking)是 RAG 中最需要细心调优的环节。不同场景、不同文档类型,最佳分块策略差异显著。

策略一:固定大小分块(Fixed-size Chunking)

最简单的方法:按字符数或 token 数切割,允许相邻块之间有重叠区域(overlap)以保持语义连贯。

from langchain_text_splitters import CharacterTextSplitter, TokenTextSplitter

# 按字符数分块
char_splitter = CharacterTextSplitter(
    chunk_size=800,      # 每块最多 800 字符
    chunk_overlap=100,   # 前后重叠 100 字符
    separator="\n\n"     # 优先在段落边界切割
)

# 按 Token 数分块(更精确,适合计算 API 成本)
token_splitter = TokenTextSplitter(
    encoding_name="cl100k_base",  # GPT-4 使用的 tokenizer
    chunk_size=256,               # 256 tokens ≈ 200 中文字
    chunk_overlap=30
)

chunks = char_splitter.split_documents(cleaned)
print(f"分块后:{len(chunks)} 个片段")

# 查看分块统计
lengths = [len(c.page_content) for c in chunks]
print(f"平均长度:{sum(lengths)/len(lengths):.0f} 字符")
print(f"最短:{min(lengths)},最长:{max(lengths)}")

策略二:递归字符分块(Recursive Character Splitting)

LangChain 最推荐的通用分块方式。它按分隔符优先级递归尝试:先按段落(\n\n),再按句子(\n),再按词(空格),最后按字符。尽可能保持语义完整性。

from langchain_text_splitters import RecursiveCharacterTextSplitter

recursive_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=150,
    separators=[
        "\n\n",   # 段落
        "\n",     # 换行
        "。",     # 中文句号
        ";",     # 中文分号
        ",",     # 中文逗号
        " ",      # 空格
        ""        # 字符级(最后手段)
    ]
)

chunks = recursive_splitter.split_documents(docs)

# 中文文档专用(按语义标点)
chinese_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
    model_name="gpt-4",
    chunk_size=300,   # tokens
    chunk_overlap=50
)

策略三:语义分块(Semantic Chunking)

最智能的分块方式:计算相邻句子之间的语义相似度,在语义跳变点切割,确保每个 chunk 内部语义连贯。

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

semantic_splitter = SemanticChunker(
    embeddings=embeddings,
    # breakpoint_threshold_type 控制切割灵敏度
    breakpoint_threshold_type="percentile",  # 或 "standard_deviation"
    breakpoint_threshold_amount=85,           # 相似度降低超过 85 百分位时切割
)

# 语义分块会调用 Embedding API,成本较高
semantic_chunks = semantic_splitter.create_documents([long_text])

策略四:父子分块(Parent-Child Chunking)

这是 Advanced RAG 的关键技术:用小块(Child)检索,用大块(Parent)生成。小块语义聚焦,检索精准;大块上下文丰富,生成质量高。

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_chroma import Chroma
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 父块:大,用于最终生成上下文
parent_splitter = RecursiveCharacterTextSplitter(
    chunk_size=2000, chunk_overlap=200
)

# 子块:小,用于精确向量检索
child_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400, chunk_overlap=50
)

vectorstore = Chroma(embedding_function=embeddings)
docstore = InMemoryStore()

retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=docstore,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

retriever.add_documents(docs)

# 检索时:用小块匹配 → 返回对应的大块
relevant_docs = retriever.invoke("RAG 的核心流程是什么?")

Chunk Size 与 Overlap 的选择指南

文档类型 推荐 chunk_size 推荐 overlap 理由
技术文档 / 论文 800–1200 字符 150–200 技术概念需要足够上下文,但不宜过大
法律合同 500–800 字符 100–150 条款边界清晰,较小块精度更高
新闻 / 博客 400–600 字符 80–100 段落短,自然边界明显
代码文件 按函数边界 0–50 使用代码专用分割器,保持函数完整性
对话记录 按轮次 1–2 轮 对话轮次是自然语义单元
经验法则

overlap 通常设为 chunk_size 的 10%–20%。过大的 overlap 会导致向量库存储冗余数据,增加存储和检索成本,但过小会导致在切割边界处的信息丢失。chunk_size=1000, overlap=150 是一个普遍可用的起始值。

分块质量评估

分块质量直接决定检索精度。评估方法:构建一组"问题-答案"标注对,测试检索系统能否找到包含答案的 chunk。

def evaluate_chunking(chunks, qa_pairs):
    """
    评估分块质量:答案命中率
    qa_pairs: [{"question": ..., "answer_keyword": ...}, ...]
    """
    hits = 0
    for qa in qa_pairs:
        # 检查答案关键词是否出现在 Top-3 检索结果中
        results = vectorstore.similarity_search(qa["question"], k=3)
        found = any(
            qa["answer_keyword"] in r.page_content
            for r in results
        )
        if found:
            hits += 1

    recall = hits / len(qa_pairs)
    print(f"答案命中率(Recall@3): {recall:.1%}")
    return recall

# 实际使用 RAGAS 框架可以更系统地评估(第9章详述)

代码文档的专用分块

from langchain_text_splitters import Language, RecursiveCharacterTextSplitter

# LangChain 内置编程语言感知分割器
python_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON,
    chunk_size=2000,
    chunk_overlap=100,
)

# 支持:PYTHON, JS, TS, JAVA, GO, RUST, CPP, RUBY, MARKDOWN, HTML, LATEX ...

python_code = """
class VectorStore:
    def __init__(self, dimension: int):
        self.vectors = []
        self.dimension = dimension

    def add(self, vector: list, metadata: dict):
        self.vectors.append({"vector": vector, "metadata": metadata})

    def search(self, query: list, top_k: int = 5):
        # 计算余弦相似度
        scores = [cosine_sim(query, v["vector"]) for v in self.vectors]
        sorted_idx = sorted(range(len(scores)), key=lambda i: -scores[i])
        return [self.vectors[i] for i in sorted_idx[:top_k]]
"""

code_chunks = python_splitter.create_documents([python_code])
# Python 分割器会在函数/类边界切割,保持代码完整性

本章总结