RAG-如何对文档分块

0 阅读14分钟

上文我们讲了RAG是如何进行数据加载的,那么文档加载完数据就能直接喂给大模型进行问答吗,答案是否定的。因为把所有的文档都一并喂给大模型,那么大模型接受的上下文是非常巨大的,这会超出大模型所支持的最大token,而且每次会话,都要把上下文喂给大模型才能回答我们问的问题,这使得大模型的响应速度会变得很慢,如果是调用在线的大模型API的话,一次问答会消耗很多的token,钱包顶不住啊。所以要将文档数据加载后,进行数据分块、向量嵌入、存入向量数据库,通过向量检索将有用的数据喂给大模型,最后生成结果返回。这一篇我们着重说明数据分块是怎么做的。

在展示文本分块前说明下什么是token

  • 在英文里,一个单词可能是一个token,也可能被拆成多个。例如:playing 可能拆成 play + ing
  • 在中文里,通常一个汉字常常接近1个token, 但也不绝对
  • 标点、空格、换行也可能占token
文档分块方法
字符分块

用单一分隔符进行文档分块,代码如下:

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import CharacterTextSplitter
​
# ---------- 1. 加载 PDF ----------
loader = PyMuPDFLoader("../document/企业财务报表分析-图表.pdf")
documents = loader.load()
​
# ---------- 2. 文档分块 ----------
# chunk_size: 每块最大字符数;chunk_overlap: 块与块之间的重叠字符数,避免语义被截断
text_splitter = CharacterTextSplitter(chunk_size=200, chunk_overlap=50)
chunks = text_splitter.split_documents(documents)
​
# ---------- 3. 打印分块内容 ----------
print("=== 文档分块结果 ===\n")
print(f"  CharacterTextSplitter      -> {len(chunks)} 块")
char_lens = [len(c.page_content) for c in chunks]
print(f"  Character:  最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens)//len(char_lens)}")
for i, chunk in enumerate(chunks, 1):
    print(f"--- 第 {i} 块 (共 {len(chunks)} 块) ---")
    print(chunk.page_content.strip() or "(空)")
    if chunk.metadata:
        print(f"[元数据] {chunk.metadata}")
    print("-" * 50)
print()

返回的部分结果:CharacterTextSplitter切分的文本

1773908015462.png (C:\Users\yd-19\AppData\Roaming\Typora\typora-user-images\1773908015462.png)

文中的CharacterTextSplitter是按照字符长度切分文本,其配置是:

  • chunk_size=500: 每块最多约为200字符
  • chunk_overlap=50: 相邻块重叠50字符,减少语言被截断
  • 不考虑语义,只看长度

这里有个问题就是虽然我们配置的文本块约为200个字符,但看返回的结果最大的文本块是1266个字符,远超200字符。这是为什么呢。因为CharacterTextSplitter的工作方式是:

  • 先用sparator把文本切开(默认是“\n\n”
  • 然后把切出来的小段尝试合并,合并到接近chunk_size为止
  • 但如果某一段本身就超过了chunk_size,它就不会再进一步切割

因为样例PDF里“\n\n”很少,CharacterTextSplitter按照“\n\n”切完块后,每段本身就很长,也不会对超长段再做二次切分。所以分块出来的结果最大文本块超过了200字符,并且切割出来的字符很不均匀。接下来我们介绍另一种分块方法。

递归分块

多级,按优先级递归分隔符,代码如下:

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
​
PDF_PATH = "../document/企业财务报表分析-图表.pdf"
​
loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
recursive_splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,
    chunk_overlap=50,
)
​
chunks = recursive_splitter.split_documents(documents)
​
​
# ---------- 3. 打印分块内容 ----------
print("=== 文档分块结果 ===\n")
print(f"  RecursiveCharacterTextSplitter      -> {len(chunks)} 块")
char_lens = [len(c.page_content) for c in chunks]
print(f"  Character:  最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens)//len(char_lens)}")
for i, chunk in enumerate(chunks, 1):
    print(f"--- 第 {i} 块 (共 {len(chunks)} 块) ---")
    print(chunk.page_content.strip() or "(空)")
    if chunk.metadata:
        print(f"[元数据] {chunk.metadata}")
    print("-" * 50)
print()

返回的部分结果:RecursiveCharacterTextSplitter切出的文本

1773911123241.png 通过结果我们可以看出,RecursiveCharacterTextSplitter切出来的文本更多,更加均匀,更接近我们设置的字符数。RecursiveCharacterTextSplitter切割分隔符是通过递归:\n\n\n空格字符,对于超长块的处理,会自动降级到更细的分隔符继续切。

1773921209044.png

我们继续观察结果得知,切出来的内容语义并不完整,一段完整的话被切成两个分块,所以也要根据文中的内容进行策略分块。

分块思想
分层分块

按照文档的章节结构、句子边界进行分块,优先保留完整的句子,在元数据中加入页码、章节、分块数量。代码如下:

import re
from copy import deepcopy
​
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
​
PDF_PATH = "../document/企业财务报表分析-图表.pdf"
MAX_CHUNK_SIZE = 500
CHUNK_OVERLAP = 50
​
CHAPTER_RE = re.compile(r"(?=(?:^|\n)[一二三四五六七八九十]+、)")
SECTION_RE = re.compile(r"(?=(?:^|\n)([一二三四五六七八九十]+))")
​
fallback_splitter = RecursiveCharacterTextSplitter(
    chunk_size=MAX_CHUNK_SIZE,
    chunk_overlap=CHUNK_OVERLAP,
    separators=["。", ";", "\n", ",", " ", ""],
    keep_separator=True,
)
​
​
def extract_heading(text: str, pattern: re.Pattern) -> str:
    """从块开头提取标题行。"""
    first_line = text.strip().split("\n")[0].strip()
    if pattern.search("\n" + first_line):
        return first_line
    return ""
​
​
def split_by_regex(text: str, pattern: re.Pattern) -> list[str]:
    """按正则切分,保留分隔符在各段开头。"""
    parts = pattern.split(text)
    result = []
    for p in parts:
        stripped = p.strip()
        if stripped:
            result.append(stripped)
    return result if result else [text]
​
​
def hierarchical_chunk(docs: list[Document]) -> list[Document]:
    full_text = "\n\n".join(doc.page_content for doc in docs)
    base_meta = docs[0].metadata if docs else {}
​
    chapters = split_by_regex(full_text, CHAPTER_RE)
    chunks: list[Document] = []
​
    for chapter_text in chapters:
        chapter_heading = extract_heading(chapter_text, CHAPTER_RE)
​
        sections = split_by_regex(chapter_text, SECTION_RE)
​
        for section_text in sections:
            section_heading = extract_heading(section_text, SECTION_RE)
​
            meta = deepcopy(base_meta)
            meta["chapter"] = chapter_heading
            meta["section"] = section_heading
​
            if len(section_text) <= MAX_CHUNK_SIZE:
                chunks.append(Document(page_content=section_text.strip(), metadata=meta))
            else:
                sub_chunks = fallback_splitter.split_text(section_text)
                for idx, sub in enumerate(sub_chunks):
                    sub_meta = deepcopy(meta)
                    sub_meta["sub_chunk"] = f"{idx + 1}/{len(sub_chunks)}"
                    chunks.append(Document(page_content=sub.strip(), metadata=sub_meta))
​
    # 过小的块(如纯章节标题)合并到下一块,避免碎片
    MIN_CHUNK_SIZE = 50
    merged: list[Document] = []
    carry = ""
    for chunk in chunks:
        if len(chunk.page_content) < MIN_CHUNK_SIZE:
            carry += chunk.page_content + "\n"
        else:
            if carry:
                chunk.page_content = carry + chunk.page_content
                carry = ""
            merged.append(chunk)
    if carry and merged:
        merged[-1].page_content += "\n" + carry.strip()
​
    return merged
​
​
# ---------- 执行 ----------
loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
chunks = hierarchical_chunk(documents)
​
# ---------- 打印结果 ----------
print(f"=== 分层分块结果(共 {len(chunks)} 块)===\n")
char_lens = [len(c.page_content) for c in chunks]
print(f"  长度统计: 最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens) // len(char_lens)}\n")
​
for i, chunk in enumerate(chunks, 1):
    ch = chunk.metadata.get("chapter", "")
    sec = chunk.metadata.get("section", "")
    sub = chunk.metadata.get("sub_chunk", "")
    label = f"[{ch}]" if ch else ""
    if sec:
        label += f" [{sec}]"
    if sub:
        label += f" (子块 {sub})"
​
    content = chunk.page_content
    preview = content[:200] + "..." if len(content) > 200 else content
    print(f"--- 第 {i}/{len(chunks)}{label} (长度: {len(content)}) ---")
    print(preview)
    print("-" * 80)
print()
​

返回的部分结果:

1773921947941.png 这种分块的方法能保留语义的完整性,切出来的块自带章节的标签,定位精准

滑动窗口分块

滑动窗口分块不看标点、不看换行、不看章节,纯按字符位置滑动。

  • 优点:块大小完全均匀,覆盖无死角(每个字符至少出现在 1~2 个块里)
  • 缺点:会从句子/词中间切断,语义完整性最差

代码如下:

from copy import deepcopy
​
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_core.documents import Document
​
PDF_PATH = "../document/企业财务报表分析-图表.pdf"
WINDOW_SIZE = 300
STEP_SIZE = 200
​
​
def sliding_window_chunk(docs: list[Document], window: int, step: int) -> list[Document]:
    """
    滑动窗口分块:固定窗口大小,按步长向前滑动。
    window - step = 重叠字符数(本例 300 - 200 = 100 字符重叠)
    """
    chunks: list[Document] = []
    for doc in docs:
        text = doc.page_content
        if not text.strip():
            continue
​
        start = 0
        chunk_idx = 0
        while start < len(text):
            end = start + window
            segment = text[start:end].strip()
            if segment:
                meta = deepcopy(doc.metadata)
                meta["chunk_index"] = chunk_idx
                meta["char_start"] = start
                meta["char_end"] = min(end, len(text))
                chunks.append(Document(page_content=segment, metadata=meta))
                chunk_idx += 1
            start += step
​
    return chunks
​
​
# ---------- 执行 ----------
loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
chunks = sliding_window_chunk(documents, window=WINDOW_SIZE, step=STEP_SIZE)
​
# ---------- 打印结果 ----------
print(f"=== 滑动窗口分块结果(window={WINDOW_SIZE}, step={STEP_SIZE}, overlap={WINDOW_SIZE - STEP_SIZE})===\n")
print(f"  共 {len(chunks)} 块")
char_lens = [len(c.page_content) for c in chunks]
print(f"  长度统计: 最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens)//len(char_lens)}\n")
​
for i, chunk in enumerate(chunks, 1):
    content = chunk.page_content
    preview = content[:200] + "..." if len(content) > 200 else content
    start = chunk.metadata["char_start"]
    end = chunk.metadata["char_end"]
    print(f"--- 第 {i}/{len(chunks)} 块 [字符 {start}~{end}] (长度: {len(content)}) ---")
    print(preview)
    print("-" * 80)
print()
​

返回部分结果:

1773922983844.png

句子边界优先分块

按照标点符号将整段文本拆成一句一句的,再把句子一句一句的往块里放,快满了就输出一块。输出一块后,不是从零开始。而是从前一块末尾回带几句(总字符数 ≤ chunk_overlap=50)作为新块的开头。回带也是以整句为单位,不会把句子劈开。

  • 优点:每个块里的句子都是完整的,embedding 质量好,检索到的上下文读起来通顺。
  • 缺点:不感知文档结构(章节/标题),可能把不同章节的内容拼到同一个块里。
import re
from copy import deepcopy
​
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
​
PDF_PATH = "../document/企业财务报表分析-图表.pdf"
CHUNK_SIZE = 300
CHUNK_OVERLAP = 50
​
​
def split_sentences_zh(text: str) -> list[str]:
    """按中文句号/问号/感叹号/分号切句,尽量保留句子语义完整。"""
    text = text.strip()
    if not text:
        return []
    parts = re.split(r"(?<=[。!?;!?;])\s*", text)
    return [p.strip() for p in parts if p.strip()]
​
​
def sentence_aware_chunk_documents(
    docs: list[Document],
    chunk_size: int,
    chunk_overlap: int,
) -> list[Document]:
    """先按句切,再按句合并;超长句再兜底按字符切分。"""
    fallback_splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
        keep_separator=True,
    )
​
    chunks: list[Document] = []
    overlap_chars = max(0, chunk_overlap)
​
    for doc in docs:
        sentences = split_sentences_zh(doc.page_content)
        if not sentences:
            continue
​
        current_sentences: list[str] = []
        current_len = 0
​
        for sentence in sentences:
            sent_len = len(sentence)
​
            # 单句本身超长,先把当前块落盘,再对超长句做兜底切分
            if sent_len > chunk_size:
                if current_sentences:
                    content = "".join(current_sentences).strip()
                    if content:
                        chunks.append(Document(page_content=content, metadata=deepcopy(doc.metadata)))
                    current_sentences = []
                    current_len = 0
​
                for sub in fallback_splitter.split_text(sentence):
                    sub = sub.strip()
                    if sub:
                        chunks.append(Document(page_content=sub, metadata=deepcopy(doc.metadata)))
                continue
​
            # 如果加上当前句会超长,则先输出当前块,再按 overlap 回带末尾句子
            if current_sentences and (current_len + sent_len > chunk_size):
                content = "".join(current_sentences).strip()
                if content:
                    chunks.append(Document(page_content=content, metadata=deepcopy(doc.metadata)))
​
                # 按字符数控制 overlap(以句子为单位回带,避免把句子切开)
                overlap_buf: list[str] = []
                overlap_len = 0
                for prev in reversed(current_sentences):
                    if overlap_len >= overlap_chars:
                        break
                    overlap_buf.insert(0, prev)
                    overlap_len += len(prev)
​
                current_sentences = overlap_buf
                current_len = sum(len(s) for s in current_sentences)
​
            current_sentences.append(sentence)
            current_len += sent_len
​
        if current_sentences:
            content = "".join(current_sentences).strip()
            if content:
                chunks.append(Document(page_content=content, metadata=deepcopy(doc.metadata)))
​
    return chunks
​
​
loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
chunks = sentence_aware_chunk_documents(
    docs=documents,
    chunk_size=CHUNK_SIZE,
    chunk_overlap=CHUNK_OVERLAP,
)
​
# ---------- 3. 打印分块内容 ----------
print("=== 文档分块结果(句子边界优先)===\n")
print(f"  Sentence-aware splitter -> {len(chunks)} 块")
char_lens = [len(c.page_content) for c in chunks]
print(
    f"  长度统计: 最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens)//len(char_lens)}"
)
for i, chunk in enumerate(chunks, 1):
    print(f"--- 第 {i} 块 (共 {len(chunks)} 块) ---")
    print(chunk.page_content.strip() or "(空)")
    if chunk.metadata:
        print(f"[元数据] {chunk.metadata}")
    print("-" * 50)
print()

返回的部分结果:

1773923355591.png

通过返回结果看,分块内的句子是完整的。这个方法与分层分块结合效果更好

父子文本分块

将文本切成子块和父块,其检索流程是,用子块向量搜索,命中子块后回溯拿到它对应的父块,把父块拼成上下文喂给LLM。

  • 子块:切的更小,用来做向量检索(更容易精准命中)。
  • 父块:比子块更大,用来给LLM作为更完整的上下文(避免只拿到碎片)。

代码如下:

import uuid
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
​
PDF_PATH = "../document/企业财务报表分析-图表.pdf"
​
loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
​
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=100)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=50)
​
parent_chunks = parent_splitter.split_documents(documents)
​
all_children = []
for parent in parent_chunks:
    parent_id = str(uuid.uuid4())[:8]
    parent.metadata["parent_id"] = parent_id
​
    children = child_splitter.split_documents([parent])
    for child in children:
        child.metadata["parent_id"] = parent_id
    all_children.extend(children)
​
# ---------- 打印父块 ----------
print(f"=== 父块(共 {len(parent_chunks)} 块,chunk_size=800)===\n")
for i, p in enumerate(parent_chunks, 1):
    pid = p.metadata["parent_id"]
    preview = p.page_content[:150] + "..." if len(p.page_content) > 150 else p.page_content
    print(f"[父块 {i}] id={pid}  长度={len(p.page_content)}")
    print(f"  {preview}")
    print()
​
# ---------- 打印子块(只展示前 3 个父块对应的子块)----------
print("=" * 80)
print(f"=== 子块(共 {len(all_children)} 块,chunk_size=200)===\n")
​
shown_parents = set()
for child in all_children:
    pid = child.metadata["parent_id"]
    if pid not in shown_parents:
        shown_parents.add(pid)
        if len(shown_parents) > 3:
            break
        print(f"  ┌─ 父块 id={pid}")
​
    siblings = [c for c in all_children if c.metadata["parent_id"] == pid]
    for j, sib in enumerate(siblings, 1):
        preview = sib.page_content[:100] + "..." if len(sib.page_content) > 100 else sib.page_content
        print(f"  │  子块 {j}/{len(siblings)}  长度={len(sib.page_content)}")
        print(f"  │  {preview}")
    print(f"  └─ 共 {len(siblings)} 个子块")
    print()
​

返回的部分结果:

1773924400688.png

1773924412258.png

检索时拿小块的 parent_id 回溯到父块,把父块的完整内容交给 LLM。

实现文本分块后的问答

说完分块思想,接下来让我们通过分块后的文本做个简单的RAG系统。实现流程如下:

RAG最小实现流程.png 在做RAG之前,有必要说明下嵌入模型和向量库。

嵌入模型

嵌入模型是把文本变成一组数字(向量)的模型,让计算机能“理解”文本的语义。

如人看到"营业收入增长"和"营收提升"会知道意思差不多,但计算机只认数字。嵌入模型的作用就是:

"营业收入增长"  →  [0.12, -0.33, 0.87, ..., 0.07]   (一个 1024 维的向量)
"营收提升"      →  [0.11, -0.31, 0.85, ..., 0.08]   (和上面很接近)
"今天天气不错"  →  [0.78,  0.42, -0.15, ..., 0.63]  (和上面离得远)
  • 语义相近->向量距离近
  • 语义无关->向量距离远

嵌入模型VS大语言模型(LLM)

嵌入模型大语言模型(LLM)
输入一段文本一段文本(提示/对话)
输出一个向量(一组数字)文本(回答/续写)
用途计算文本相似度、检索理解问题、生成回答
RAG 中的角色负责找到相关文档片段负责根据片段回答问题

我用的线上嵌入模型是BAAI/bge-large-zh-v1.5,支持最大512个的token输入长度。

1773909383550.png

向量数据库

专门用来存储向量,按相似度搜索向量的数据库。文本切成块之后就会被嵌入模型转成向量,存入向量数据库。

传统数据库向量数据库
存什么行、列、文本、数字向量(一组浮点数)
怎么查WHERE name = '张三'(精确匹配)"找最像这个向量的 Top-K"(相似度匹配)
核心算法B-tree 索引ANN(近似最近邻)索引
实现代码
"""
基于 PDF 的 RAG 问答脚本:
加载 PDF → 分块 → 将分块内容作为上下文 → 使用 LLM 回答用户问题。
"""import os
from dotenv import load_dotenv
​
load_dotenv()
​
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_openai import OpenAIEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore
​
# ---------- 1. 加载 PDF ----------
loader = PyMuPDFLoader("../document/企业财务报表分析-图表.pdf")
documents = loader.load()
​
# ---------- 2. 文档分块 ----------
# chunk_size: 每块最大字符数;chunk_overlap: 块与块之间的重叠字符数,避免语义被截断
text_splitter = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50)
chunks = text_splitter.split_documents(documents)
​
# ---------- 3. 打印分块内容 ----------
print("=== 文档分块结果 ===\n")
for i, chunk in enumerate(chunks, 1):
    print(f"--- 第 {i} 块 (共 {len(chunks)} 块) ---")
    print(chunk.page_content.strip() or "(空)")
    if chunk.metadata:
        print(f"[元数据] {chunk.metadata}")
    print("-" * 50)
print()
​
# ---------- 4. 配置 LLM(代理地址与 API Key 从 .env 读取) ----------
llm = ChatOpenAI(
    model=os.getenv("PROXY_AI_MODEL", "gemini-2.5-flash"),
    base_url=os.getenv("PROXY_AI_BASE_URL"),
    api_key=os.getenv("PROXY_AI_API_KEY"),
    temperature=0.3,
    max_tokens=1024,
)
​
embeddings = OpenAIEmbeddings(
    model="BAAI/bge-large-zh-v1.5",
    api_key=os.getenv("SILICONFLOW_API_KEY"),
    base_url="https://api.siliconflow.cn/v1",
    chunk_size=32,
)
​
vector_store = InMemoryVectorStore.from_documents(chunks, embeddings)
​
# ---------- 5. 构建提示与调用链 ----------
# 系统消息中注入 PDF 上下文,用户消息为问题
prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "你是一个助手。请仅根据下面「PDF 内容」回答用户问题,不要编造。回答简洁。\n\nPDF 内容:\n{context}",
        ),
        ("human", "{question}"),
    ]
)
chain = prompt | llm
​
# ---------- 6. 交互式问答 ----------
print("基于 PDF 的问答(输入空行回车退出)\n")
while True:
    question = input("你的问题: ").strip()
    if not question:
        break
    # 把问题做成向量检索
    retrieved = vector_store.similarity_search(question, k=8)
    context = "\n\n".join(doc.page_content for doc in retrieved)
    answer = chain.invoke({"context": context, "question": question})
    print(f"回答: {answer.content}\n")
​

返回部分结果: 1773927282417.png

回答的结果对比文档出处:

1773927410613.png

1773927360666.png

1773927327431.png

总结
  • 字符分块:按一个分隔符切一次,超长也不管。
  • 递归分块:多级分隔符递归切,尽量控制块大小。
  • 句子边界:以句子为最小单位,不在句中截断。
  • 层级分块:先按章节结构切,再对超长段做二次切。
  • 滑动窗口:按固定字符数滑窗,重叠一段,块大小均匀。
  • 父子分块:小块检索、大块回答,检索细、回答有上下文。
结尾

文本分块的目的,是让每块内容更聚焦、语义更完整,从而提升RAG系统的检索准确度。好了,文档分块的内容就分享到这儿。在座的彦祖、亦菲们有什么好的文档分块方法,也欢迎到评论区讨论哦!