乐于分享
好东西不私藏

L2-1:RAG 大文档怎么喂给AI?文本分块的切分艺术

L2-1:RAG 大文档怎么喂给AI?文本分块的切分艺术

RAG 通关系列文章

第1关|别让垃圾数据污染你的AI大脑:数据清洗避坑指南

   第2关|大文档怎么喂给AI?文本分块的切分艺术

   第3关|把文字变成数字:向量化到底是什么魔法

   第4关|存得进还要找得出:向量库索引构建实战

   第5关|AI怎么“懂”你的问题?语义向量化核心解密

   第6关|Milvus、Chroma、Weaviate…向量库到底选哪个?

   第7关|大海捞针不迷路:向量召回策略全解析

   第8关|召回只是第一步,Rerank才是精准命中的关键

   第9关|终极一击:如何把检索结果组装成高质量Prompt

🎯 学习价值

读者学完这9关后,将能够:

1.理解RAG全流程原理
2.掌握每个环节的核心技术
3.避开常见坑点
4.独立构建生产级RAG系统

你有没有遇到过这样的情况:辛辛苦苦把公司几百页的技术文档导入了RAG系统,心想这下知识库齐活了。结果一测试,问”Redis怎么配置集群”,系统给你召回了一段讲Redis安装,另一段讲Java代码规范,还有一段是”3.2.1 章节目录”——完全答非所问。

你可能会想:是不是向量化有问题?是不是召回策略不够好?

但打开向量库一看,傻眼了——文档被切成了几千个碎片,每个碎片只有一两句话,上下文全丢了;有些碎片干脆把”Redis集群”这个词拆成了”Redis”和”集群”两半,分别存到了不同的块里。

这就是文本分块的问题。

为什么分块这么重要?

先给你讲个我亲历的案例。

在以前为某公司搭建了一个法律文档问答系统,用了5万份合同、法律条文。团队把文档按每500字符一切,想着”小块更精准”。结果用户问”合同违约金怎么算”,系统召回了三段内容:

块1:...违约金按照实际损失计算,但不得超过合同总金额的30%...
块2:...计算方式为:违约金 = 损失金额 × 违约系数...
块3:...甲方未按时付款的,应支付违约金...

看起来都对,但问题来了:块1说的是”合同违约金上限”,块2说的是”计算公式”,块3说的是”甲方违约情形”。这三段来自同一份文档的不同章节,本来应该作为一个完整的答案,但被切散了,检索时虽然都召回了,却因为顺序和权重问题,LLM最终生成的答案支离破碎。

更糟糕的是,还有个块4:

块4:...租赁合同的违约金为月租金的200%,计算方式见附件三...

这个块被错误地召回了,因为它也包含”违约金”和”计算”两个关键词。但这是租赁合同的条款,和用户问的采购合同完全是两码事。

问题出在哪?分块策略太粗暴。500字符一切,不考虑段落、句子边界,更不考虑文档结构。结果语义被切断,噪音被引入,召回质量直线下降。

分块在整个RAG流程中的位置

分块是RAG流程中承上启下的关键环节:

向上承接数据清洗:清洗后的文档需要切分成合适的块才能向量化
向下影响向量检索:块的大小、质量直接决定召回的准确度
最终影响答案生成:块是否完整、是否包含足够的上下文,决定LLM能不能生成好答案

可以说,分块策略选得对不对,直接决定了你的RAG系统是”精准狙击”还是”撒网捕鱼”

四种分块策略,各有千秋

分块策略没有绝对的好坏,关键看你的文档类型和应用场景。在这里我一个一个讲下。

策略一:固定长度分块——简单但粗暴

最原始的方法:按字符数或token数直接切。

from langchain.text_splitter import CharacterTextSplitter

splitter = CharacterTextSplitter(
    chunk_size=500,      # 每500个字符切一块
    chunk_overlap=50,    # 相邻块重叠50个字符
    separator='\n\n'     # 按段落切
)
chunks = splitter.split_text(text)

这个方法有个致命问题:它会在第500个字符处”咔嚓”一刀,不管那里是句子中间还是词中间。

举个真实的例子:

原文:"检索增强生成(RAG)是一种结合检索和生成的AI技术,它通过从知识库中检索相关文档来增强大语言模型的生成能力。"

分块后:
块1:"检索增强生成(RAG)是一种结合检索和生成的AI技术,它通过从知识库中检索相关文档来增强大语言模型的生成能"
块2:"力。"

“生成能力”这个词被切成了”生成能”和”力”两部分。向量化后,这两个块生成的向量都和原意相差甚远,检索时根本匹配不上。

什么时候用?

  • • 快速原型验证
  • • 文档格式比较统一、段落边界清晰固定的场景
  • • 作为baseline对比其他策略

什么时候别用?(基本就是除了上面那三种)

  • • 专业术语多的文档(医疗、法律、技术)
  • • 代码文件
  • • 语义密度高的内容

策略二:递归字符分块——LangChain的默认推荐

这个策略聪明多了。它不是”一刀切”,而是按优先级依次尝试不同的分隔符:

优先级1:按段落切(\n\n)
如果还是太大 → 优先级2:按句子切(\n)
还是太大 → 优先级3:按词语切(空格)
还是太大 → 优先级4:按字符切

这样,它会优先在语义边界处切分,尽量保持句子和段落的完整性。

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=700,
    chunk_overlap=100,
    separators=["\n\n", "\n", "。", "!", "?", " ", ""]
)
chunks = splitter.split_text(text)

注意中文场景,我加了。!?作为句子分隔符,比英文的句号更准确。

实际效果对比

还是刚才那段话,用递归分块:

块1:"检索增强生成(RAG)是一种结合检索和生成的AI技术,它通过从知识库中检索相关文档来增强大语言模型的生成能力。"

完整的句子被保留下来了。向量化后,这个块能准确匹配到”RAG技术”、”检索增强生成”等相关查询。

什么时候用?

  • • 通用文本(新闻、博客、文档)
  • • 没有特殊结构的文档
  • • 作为默认分块策略

策略三:按文档结构分块——结构化文档的最佳选择

如果你的文档有明确的层级结构(Markdown的标题、HTML的h1/h2、PDF的章节),那就应该利用这个结构。

Markdown文档

from langchain.text_splitter import MarkdownTextSplitter

splitter = MarkdownTextSplitter(
    chunk_size=500,
    chunk_overlap=50
)
chunks = splitter.split_text(markdown_text)

它会尊重Markdown的标题结构,让每个标题下的内容成为相对独立的块。

HTML文档

from langchain.text_splitter import HTMLHeaderTextSplitter

splitter = HTMLHeaderTextSplitter(
    headers_to_split_on=[
        ("h1", "header1"),
        ("h2", "header2"),
        ("h3", "header3"),
    ]
)
chunks = splitter.split_text(html_text)

这样,一个h2标题下的所有内容会被归为一个块,标题和内容不会被拆散。

实际案例

我有一次处理企业内部Wiki,文档是Markdown格式的。一开始用递归分块,结果”## 3.2 Redis集群配置”这个标题和下面的内容被切开了。检索”Redis集群怎么配置”时,召回了内容块,但标题块没召回,导致答案缺乏上下文。

后来改用MarkdownTextSplitter,每个标题+内容作为一个整体,召回质量立刻提升。

策略四:代码语法感知分块——程序员的福音

代码文件绝对不能乱切。你见过把一个函数切成两半的吗?切完后上半部分是函数定义,下半部分是函数体,检索时匹配到函数体,但不知道是哪个函数——完全没意义。

LangChain支持按编程语言语法切分,自动识别函数、类等语法边界:

from langchain.text_splitter import RecursiveCharacterTextSplitter, Language

# Python代码

py_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON,
    chunk_size=300,
    chunk_overlap=0
)

# JavaScript代码

js_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.JS,
    chunk_size=300,
    chunk_overlap=0
)

chunks = py_splitter.split_text(python_code)

目前支持26种编程语言:Python、JavaScript、Java、Go、Rust、C++、TypeScript、SQL、HTML、CSS…

代码分块的特殊性

  1. 1. overlap通常设为0:代码的语义边界很清晰,overlap反而会引入冗余
  2. 2. chunk_size要小:一个函数通常200-500 tokens就够,太大的块会让检索变模糊
  3. 3. 保留元数据:代码块最好带上文件名、函数名,方便追溯

chunk_size怎么选?这是个艺术

这是被问到最多的问题。结合我的实际经验,给你一个实操指南:

根据文档类型选择

文档类型
推荐chunk_size
overlap
理由
通用文本
500-750 tokens
10-20%
平衡召回精度和上下文完整性
技术文档
512-1024 tokens
15%
术语密度高,overlap防止切断
代码文件
200-500 tokens
0
语法边界清晰,不需要overlap
问答/FAQ
300-500 tokens
10%
一问一答自成体系
法律/医疗
400-600 tokens
25-30%
语义密度极高,overlap要大

根据嵌入模型调整

如果你用的是OpenAI的text-embedding-3-small,它的上下文窗口是8191 tokens。但并不意味着你的chunk_size可以设到8000——那样检索时会召回一大块,里面大部分内容可能和问题无关。

一般建议chunk_size不要超过嵌入模型窗口的1/10到1/8,这样召回的块更精准。

根据召回效果调优

没有万能公式,最好的办法是A/B测试:

  1. 1. 准备一批测试查询(比如100个)
  2. 2. 用不同的chunk_size分别构建向量库
  3. 3. 对比召回的准确率、召回率
  4. 4. 选择效果最好的参数

我的经验:512 tokens + 15% overlap是一个不错的起点,适合大多数场景。

三大分块陷阱,一个比一个致命

陷阱一:语义切断,专业术语被腰斩

这是我见过最惨的案例。一个医疗问答系统,文档里有大量医学术语,比如”糖化血红蛋白测定”。用固定长度分块后,这个词被切成了:

块1:"...糖化血红蛋白测"
块2:"定..."

用户问”糖化血红蛋白怎么测”,系统召回的是”糖化血红蛋白测”这个块,但后面被切掉了,LLM生成的答案支离破碎。

怎么避免?

  • • 用递归分块,让它先尝试按句子切
  • • 对专业术语多的文档,把overlap提高到25-30%
  • • 考虑Sentence Window策略:检索时用小块,但返回时带上父块的上下文
# Sentence Window实现思路
def
 get_context_window(chunk_id, all_chunks, window_size=2):
    """获取一个块周围的上下文窗口"""

    start = max(0, chunk_id - window_size)
    end = min(len(all_chunks), chunk_id + window_size + 1)
    return
 all_chunks[start:end]

陷阱二:chunk_size一刀切,不分文档类型

有个团队把所有文档——代码、Markdown、PDF、HTML——统统用chunk_size=500切。结果代码被切成单字符没了意义,Markdown的标题和内容被拆散,HTML的标签残留把向量”毒化”了。

怎么避免?

根据文档类型选择不同的分块策略:

def get_splitter_for_file(filepath):
    """根据文件类型返回合适的分块器"""

    ext = filepath.split('.')[-1].lower()

    # 代码文件

    if
 ext in ['py', 'js', 'java', 'go', 'rs']:
        lang_map = {
            'py'
: Language.PYTHON,
            'js'
: Language.JS,
            'java'
: Language.JAVA,
        }
        return
 RecursiveCharacterTextSplitter.from_language(
            language=lang_map.get(ext, Language.PYTHON),
            chunk_size=300,
            chunk_overlap=0
        )

    # Markdown文档

    elif
 ext in ['md', 'markdown']:
        return
 MarkdownTextSplitter(
            chunk_size=500,
            chunk_overlap=50
        )

    # HTML文档

    elif
 ext in ['html', 'htm']:
        return
 HTMLHeaderTextSplitter(
            headers_to_split_on=[("h1", "h1"), ("h2", "h2")]
        )

    # 通用文本

    else
:
        return
 RecursiveCharacterTextSplitter(
            chunk_size=700,
            chunk_overlap=100
        )

陷阱三:忽略文档结构,标题和内容分家

前面提到的法律文档案例,问题就出在这里。文档有明确的章节结构(第一章、第二章…),但分块时完全忽略了这些结构,导致召回时只能拿到孤立的内容片段。

怎么避免?

  • • Markdown文档优先用MarkdownTextSplitter
  • • HTML文档优先用HTMLHeaderTextSplitter
  • • PDF文档如果有目录,可以用目录信息指导分块
  • • 没有结构信息的文档,考虑用NLP工具自动识别段落结构
# 带标题元数据的分块
from
 langchain.text_splitter import MarkdownTextSplitter

splitter = MarkdownTextSplitter(chunk_size=500)
docs = splitter.create_documents([markdown_text])

# 每个文档块会自动带上标题元数据

for
 doc in docs:
    print
(f"标题: {doc.metadata.get('headers', [])}")
    print
(f"内容: {doc.page_content[:50]}...")

分块后的元数据,别浪费了

很多人分完块就把元数据扔了,这是巨大的浪费。

元数据可以帮你:

1. 过滤召回结果

# 只召回某个章节的内容
retriever = vectorstore.as_retriever(
    search_kwargs={
        "k"
: 5,
        "filter"
: {"chapter": "第3章"}
    }
)

2. 排序优化

召回后,可以按元数据排序:最新的文档排前面、重要章节排前面。

3. 可追溯性

回答里可以标注”根据《XX文档》第X章”,用户可以溯源验证。

应该保留哪些元数据?

  • • 文档来源(文件名、URL)
  • • 章节信息(标题、层级)
  • • 时间戳(创建时间、更新时间)
  • • 文档类型(代码、文档、FAQ)
  • • 自定义标签(重要、最新、已验证)

一个完整的分块Pipeline

最后,给你一个我经常用的可以直接上生产的分块Pipeline代码,覆盖了常见的文档类型,强烈建议收藏备用

from langchain.text_splitter import (
    RecursiveCharacterTextSplitter,
    MarkdownTextSplitter,
    HTMLHeaderTextSplitter,
    Language
)
from
 langchain.schema import Document
from
 typing import List, Dict, Optional
import
 os

class
 SmartSplitter:
    """智能分块器:根据文档类型自动选择策略"""


    def
 __init__(self):
        self
.splitter_map = {}

    def
 split_document(
        self, 
        content: str
        filepath: str,
        metadata: Optional[Dict] = None
) -> List[Document]:
        """根据文件类型智能分块"""


        ext = os.path.splitext(filepath)[1].lower()
        metadata = metadata or {}
        metadata['source'] = filepath

        # 代码文件

        if
 ext in ['.py', '.js', '.java', '.go', '.rs', '.ts']:
            chunks = self._split_code(content, ext, metadata)

        # Markdown

        elif
 ext in ['.md', '.markdown']:
            chunks = self._split_markdown(content, metadata)

        # HTML

        elif
 ext in ['.html', '.htm']:
            chunks = self._split_html(content, metadata)

        # 通用文本

        else
:
            chunks = self._split_text(content, metadata)

        return
 chunks

    def
 _split_code(self, code: str, ext: str, metadata: Dict) -> List[Document]:
        """代码分块"""

        lang_map = {
            '.py'
: Language.PYTHON,
            '.js'
: Language.JS,
            '.java'
: Language.JAVA,
            '.go'
: Language.GO,
            '.ts'
: Language.TS,
        }

        splitter = RecursiveCharacterTextSplitter.from_language(
            language=lang_map.get(ext, Language.PYTHON),
            chunk_size=300,
            chunk_overlap=0
        )

        chunks = splitter.split_text(code)
        return
 [
            Document(page_content=chunk, metadata={**metadata, 'chunk_id': i})
            for
 i, chunk in enumerate(chunks)
        ]

    def
 _split_markdown(self, text: str, metadata: Dict) -> List[Document]:
        """Markdown分块"""

        splitter = MarkdownTextSplitter(
            chunk_size=500,
            chunk_overlap=50
        )

        chunks = splitter.split_text(text)
        return
 [
            Document(page_content=chunk, metadata={**metadata, 'chunk_id': i})
            for
 i, chunk in enumerate(chunks)
        ]

    def
 _split_html(self, text: str, metadata: Dict) -> List[Document]:
        """HTML分块"""

        splitter = HTMLHeaderTextSplitter(
            headers_to_split_on=[
                ("h1", "h1"),
                ("h2", "h2"),
                ("h3", "h3"),
            ]
        )

        chunks = splitter.split_text(text)
        # 添加额外元数据

        for
 i, chunk in enumerate(chunks):
            chunk.metadata.update(metadata)
            chunk.metadata['chunk_id'] = i

        return
 chunks

    def
 _split_text(self, text: str, metadata: Dict) -> List[Document]:
        """通用文本分块"""

        splitter = RecursiveCharacterTextSplitter(
            chunk_size=700,
            chunk_overlap=100,
            separators=["\n\n", "\n", "。", "!", "?", " ", ""]
        )

        chunks = splitter.split_text(text)
        return
 [
            Document(page_content=chunk, metadata={**metadata, 'chunk_id': i})
            for
 i, chunk in enumerate(chunks)
        ]


# 使用示例

if
 __name__ == "__main__":
    splitter = SmartSplitter()

    # 测试Python代码

    py_code = """
def calculate_rag_score(query, documents):
    '''计算RAG相关性得分'''
    scores = []
    for doc in documents:
        score = cosine_similarity(query, doc)
        scores.append(score)
    return sorted(scores, reverse=True)
"""


    chunks = splitter.split_document(py_code, "test.py")
    print
(f"Python代码分块:{len(chunks)}个块")
    for
 chunk in chunks:
        print
(f"块{chunk.metadata['chunk_id']}: {chunk.page_content[:50]}...")

    # 测试Markdown

    md_text = """
# RAG技术详解

## 什么是RAG
RAG是检索增强生成的缩写...

## RAG的优势
RAG有以下优势...
"""


    chunks = splitter.split_document(md_text, "test.md")
    print
(f"\nMarkdown分块:{len(chunks)}个块")

本关小结

文本分块是RAG系统的”手术刀”,切得好,召回精准;切不好,就是乱砍。

记住这三点:

  1. 1. 策略要匹配文档类型——代码按语法切、Markdown按结构切、通用文本用递归切
  2. 2. 参数要根据场景调优——512 tokens + 15% overlap是不错的起点,但不是万能公式
  3. 3. 避免语义切断——专业术语多的文档,overlap要放大;结构化文档,优先用结构感知分块器

下一关,我们讲向量化。你可能会问”向量化不就是调用个API吗”,但这里面的门道比你想象的多:选哪个模型?中英文怎么处理?维度越高越好吗?

这些问题,下一关我们细细讲来:第3关|把文字变成数字:向量化到底是什么魔法。


思考题

  1. 1. 你的项目里,文档类型有哪些?每种类型应该用什么分块策略?
  2. 2. 你有没有遇到过召回的内容和问题相关但不完整的情况?是不是分块切断了上下文?

欢迎在评论区分享你的经验。