RAG 文档切分完全指南:从一次"答不上手册里明明写着的答案"看懂 Chunking 为什么决定检索质量上限

2024 年我做一个文档问答系统用户上传一份产品手册然后能用自然语言向它提问系统去手册里找答案也就是现在常说的 RAG 它的核心套路我很清楚把文档切成一块一块每块算一个向量存进向量库用户提问时把问题也算成向量检索出最相似的几块连同问题一起喂给大模型作答这一整条链路里有一步叫把文档切成块怎么切这件事我没多想就有了方案按字数切第一版我做得很顺手我写了个函数把文档每五百个字切成一块一块接一块整整齐齐本地拿一份手册一测提了几个问题答得还像模像样我心里很笃定切块嘛不就是把长文档分成小段按字数均匀切最简单可等这个系统真正面对五花八门的真实文档和真实提问一串问题冒了出来第一种最先把我打懵有些问题手册里明明白白写着答案系统却答不上来第二种最难缠我去看检索召回的内容发现一个关键句子被从正中间切断了第三种最头疼有个问题召回的块里那一句被一大堆无关内容埋着第四种最莫名其妙有一块召回出来是它支持三种部署模式可它指的是什么根本找不到我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为切块就是个机械的分割动作把长文档按固定字数均匀切开切得整齐就行可切块根本不是一个无关紧要的预处理步骤它直接决定了整个 RAG 系统检索质量的上限本文从头梳理为什么按固定字数切会切碎语义怎么按语义边界切为什么块之间要留重叠块的大小该怎么权衡怎么给块保留住它的上下文以及一些把它做扎实要避开的工程坑

2024 年我做一个文档问答系统,用户上传一份产品手册,然后能用自然语言向它提问,系统去手册里找答案——也就是现在常说的 RAG。它的核心套路我很清楚:把文档切成一块一块,每块算一个向量存进向量库,用户提问时把问题也算成向量,检索出最相似的几块,连同问题一起喂给大模型作答。这一整条链路里,有一步叫"把文档切成块",怎么切?这件事我没多想,就有了方案:按字数切。第一版我做得很顺手——我写了个函数,把文档每 500 个字切成一块,一块接一块,整整齐齐。本地拿一份手册一测,提了几个问题,答得还像模像样。我心里很笃定:切块嘛,不就是把长文档分成小段,按字数均匀切最简单,这套 RAG 稳了。可等这个系统真正面对五花八门的真实文档和真实提问,一串问题冒了出来。第一种最先把我打懵:有些问题,手册里明明白白写着答案,系统却答不上来,或者答得驴唇不对马嘴。第二种最难缠:我去看检索召回的内容,发现一个关键句子被从正中间切断了,前半句在一块、后半句在另一块,两块单独看都不成意思。第三种最头疼:有个问题召回的块里,确实有一句是对的,可那一块足足几百字,那一句被一大堆无关内容埋着,模型被这堆噪声带偏了。第四种最莫名其妙:有一块召回出来是"它支持三种部署模式",可"它"指的是什么,在这一块里根本找不到——主语在上一块里。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为切块就是个机械的分割动作,把长文档按固定字数均匀切开,切得整齐就行。可切块根本不是一个无关紧要的预处理步骤,它直接决定了整个 RAG 系统检索质量的上限。道理很硬:检索时,被召回的最小单位就是"块";如果一个块本身语义就是残缺的、是噪声混着信息的、是缺了上下文的,那么无论后面的向量检索多准、大模型多强,它拿到的原料就是坏的,做不出好菜。按固定字数切,正是在用一把对文档语义结构一无所知的尺子,生硬地裁剪文档——它必然会把完整的句子、段落、表格拦腰切断。要把 RAG 做扎实,根上要明白:切块的目标不是"切得均匀",而是"让每一块都尽量是一个语义完整、大小适中、自带必要上下文的单元"。本文从头梳理:为什么按固定字数切会切碎语义,怎么按语义边界切,为什么块之间要留重叠,块的大小该怎么权衡,怎么给块保留住它的上下文,以及一些把它做扎实要避开的工程坑。

问题背景

先把 chunking 在 RAG 里的位置说清楚。一个 RAG 系统,离线阶段要做的事是:把文档切块、给每块算向量、存进向量库。在线阶段:把用户问题算成向量,去向量库里检索最相似的若干块,把这些块作为"资料"和问题一起塞进大模型的 prompt,让模型基于资料作答。整条链路里,chunking 是第一步,也是最容易被当成"小事"的一步。

错误认知是:切块只是个把长文本分小的机械操作,按固定长度切最省事,切多大、在哪切都无所谓。真相是:块是检索和召回的最小单位,块的质量,就是喂给模型的原料的质量,它给整个系统的效果设了一个上限。把这一点摊开,第一版的几类问题就都能解释了:

  • 语义被拦腰切断:固定字数的切点落在句子、段落中间,一个完整的意思被劈成两半,两个半块单独都检索不出、也表达不清原意。
  • 块太大引入噪声:一个大块里只有一两句和问题相关,其余几百字都是无关内容,既稀释了向量的语义、又在 prompt 里挤占 token、干扰模型。
  • 块太小丢失上下文:块小到不足以自我解释,出现"它""该功能"这类指代,而指代的对象在别的块里,模型无从理解。
  • 结构信息被拍平:文档的标题层级、表格、列表在切块时被当成纯文本拍平,召回的块不知道自己属于哪一章、哪个主题。

所以 chunking 要做对,核心不是把切分写得更快,而是让切分这个动作"读懂"文档的语义结构,切出的每一块都尽量自成一个完整、干净、带上下文的单元。下面六节,就从第一版的固定字数切讲起,一步步把它改对。

一、为什么"按固定字数切"会切碎语义

第一版的切法,业内叫固定大小切分:不看内容,只数字数,数满 500 就一刀切下。它唯一的优点是简单。但它有一个根本的盲点:它对文档的语义结构完全无知。文档不是一串均匀的字符流,它是有结构的——由章节、段落、句子层层组成,每一级都是一个有意义的单元。固定字数的那把尺子,完全无视这套结构,它的切点纯粹由"数到第 500 个字"决定,而第 500 个字落在哪里,是随机的:可能正好落在一句话的中间,落在一个词的中间,落在一个表格的中间。

# 反面教材:按固定字数切,完全无视文档的语义结构

def fixed_size_split(text: str, chunk_size: int = 500) -> list:
    chunks = []
    for i in range(0, len(text), chunk_size):
        # 切点纯粹由字数决定,落在句子中间也照切不误
        chunks.append(text[i:i + chunk_size])
    return chunks

doc = ("产品支持三种部署模式。第一种是单机模式,适合开发测试,"
       "所有组件运行在同一台机器上,资源占用最低但不具备高可用。"
       "第二种是集群模式,适合生产环境……")  # 一份真实文档会长得多

for idx, c in enumerate(fixed_size_split(doc, 40)):
    print(f"[块{idx}] {c}")
# 你会看到某一块以"第二种是集"结尾,下一块以"群模式"开头
# 一个完整的概念被生生劈成两半,两块单独看都不知所云

这种切法的危害,会沿着 RAG 的链路一路放大。第一步,算向量时:一个残缺的半句话,算出来的向量本身就语义模糊——它既不完整代表前半句的意思,也不代表后半句。第二步,检索时:用户的问题向量,很难和这个模糊的向量对得上,该被召回的内容召不回来。第三步,就算侥幸召回了:模型拿到的是半句话,它要么理解错,要么干脆答不出。第一版"答不上明明写着的答案",根子就在这里——不是检索不行,是被检索的那个"块",从出生起就是残缺的。

这一节要建立的认知是:文档是有语义结构的,而固定字数切分用的是一把对这套结构完全无知的尺子。切分这个动作的本质,是"决定在哪里下刀"。下刀的位置好不好,标准只有一个:是否落在了语义的自然缝隙上。句子和句子之间、段落和段落之间,是天然的缝隙,在那里下刀,两边都是完整的。而一个句子的内部、一个词的内部,是语义最紧密的地方,在那里下刀就是破坏。固定字数切分的悲剧在于,它的下刀位置和语义缝隙毫无关系,纯属碰运气——而文档那么长,运气好的切点是少数,绝大多数刀都砍在了不该砍的地方。要修这个问题,就得换一把能"看见"语义缝隙的尺子。

二、按语义边界切:段落、句子优先

换尺子的思路,叫递归字符切分。它的核心想法是:文档的语义结构是分级的,切分时也要按级来,优先在大的语义缝隙下刀,大缝隙不够用时再退而求其次找小缝隙。具体说,先尝试按"段落"切(通常是连续两个换行);如果某一段还是太长、超过了目标大小,再在这一段内部按"句子"切(句号、问号、感叹号);如果某个句子还是太长,才最后退回到按字符硬切。这样,下刀的位置总是尽可能落在最大的那个语义缝隙上。

# 递归字符切分:优先按大的语义边界切,不够才退到小的

def recursive_split(text: str, chunk_size: int,
                    separators: list = None) -> list:
    # 分隔符按"语义粒度从大到小"排列
    if separators is None:
        separators = ["\n\n", "\n", "。", "!", "?", " ", ""]

    # 文本已经够短,直接作为一块
    if len(text) <= chunk_size:
        return [text] if text.strip() else []

    sep = separators[0]
    rest = separators[1:]
    # 用当前这一级分隔符切开
    parts = text.split(sep) if sep else list(text)

    chunks, buffer = [], ""
    for part in parts:
        piece = part + sep if sep else part
        # 累加还不超尺寸,就先攒着
        if len(buffer) + len(piece) <= chunk_size:
            buffer += piece
        else:
            if buffer:
                chunks.append(buffer)
            # 单个 part 自己就超了:用更细的分隔符递归切它
            if len(piece) > chunk_size and rest:
                chunks.extend(recursive_split(piece, chunk_size, rest))
                buffer = ""
            else:
                buffer = piece
    if buffer:
        chunks.append(buffer)
    return [c for c in chunks if c.strip()]

这套切法和固定字数切的差别,是质的差别。固定字数切是"先决定切点,内容被动接受";递归切分是"先尊重内容的结构,切点服从结构"。绝大多数块,会恰好是一个或几个完整的段落、完整的句子。只有在极少数情况下——某个句子本身就长得超过了块大小——它才不得不退回去做字符硬切,而这种情况在正常文档里很罕见。

这一节的认知是:好的切分,是让切点去适应文档,而不是让文档去迁就切点。固定字数切的思维是"我有一把 500 字的尺子,文档你来配合我";递归切分的思维是"文档你有怎样的结构,我顺着你的结构来下刀"。这个主从关系一颠倒,切出来的块的质量就天差地别。这里还藏着一个更普遍的道理:处理任何有结构的数据——文档、代码、日志、HTML——都应该先识别并尊重它的固有结构,而不是把它当成无结构的字节流来硬处理。一旦你开始"按结构办事",很多本来很别扭的问题会自然消失。递归切分,就是把这个道理用在了文档切块上。

三、给块加重叠:防止边界信息丢失

按语义边界切,已经能保证大多数块内部是完整的了。但还有一个边界问题没解决:有些信息的完整含义,是跨越段落边界的。比如一段讲"集群模式",下一段开头说"在这种模式下,需要至少三个节点"——"这种模式"指的就是上一段的集群模式。如果切点恰好落在两段之间,"需要三个节点"这条信息所在的块,就丢掉了"是哪种模式"这个关键前提。解决办法是让相邻的块之间有一段重叠:每一块的开头,带上前一块结尾的一小段内容。

# 给相邻块加重叠:每块开头带上前一块结尾的一段

def split_with_overlap(text: str, chunk_size: int,
                       overlap: int) -> list:
    # 先按语义边界切成基础块
    base = recursive_split(text, chunk_size)

    if overlap <= 0 or len(base) <= 1:
        return base

    overlapped = [base[0]]
    for i in range(1, len(base)):
        prev = base[i - 1]
        # 取前一块结尾的 overlap 个字,拼到当前块前面
        tail = prev[-overlap:] if len(prev) > overlap else prev
        overlapped.append(tail + base[i])
    return overlapped

# overlap 一般取 chunk_size 的 10% 到 20%
chunks = split_with_overlap(long_doc, chunk_size=500, overlap=80)
# 这样"在这种模式下"那一块,开头就带着上一块结尾
# 提到的"集群模式",指代不再悬空

重叠的大小是有讲究的。太小,起不到衔接作用;太大,相邻块的内容重复太多,既浪费存储,又会让检索时召回一堆内容高度雷同的块。常见的取值是块大小的百分之十到百分之二十。它本质是花一点冗余,买一份"边界处不丢信息"的保险。

这一节的认知是:无论你的切分边界找得多准,"被切开"这个动作本身,就一定会切断某些跨边界的语义联系。第二节让切点落在语义缝隙上,解决的是"不要切碎一个完整单元";但即便切点完美,相邻两个单元之间那种"承上启下"的联系,还是会被切断——因为它们本来就是被一个边界分开的。重叠就是承认这一点之后给出的补偿:既然边界两侧的联系会断,那就让每一块多带一点"邻居的尾巴",把那条被切断的线,在块的内部重新接上一截。这是一种典型的工程权衡——用可控的冗余,换取信息完整性。理解了"切分必有损失、重叠是对损失的补偿",你就不会再纠结"到底该不该重叠",而会去想"重叠多少最划算"。

四、块的大小是个权衡:不是越大越好也不是越小越好

前面一直在用 chunk_size 这个参数,但还没正面回答:它到底该设多大。这是 chunking 里最关键、也最被反复纠结的一个决策。先说清楚两个方向的代价。块太大:一个块里塞了好几个主题,它的向量是这几个主题的"平均",语义模糊,检索精度差;而且召回后,一大块内容里只有一小部分有用,其余都是噪声,既挤占 prompt 的 token,又干扰模型。块太小:块倒是干净了,但常常小到语义不完整,出现悬空的指代,而且检索时要召回很多个小块才能凑齐回答一个问题所需的信息。

# 块大小要按 token 计量,并显式控制在一个合理区间
import tiktoken

_enc = tiktoken.get_encoding("cl100k_base")

def token_len(text: str) -> int:
    # 用 token 数而不是字符数衡量块大小
    return len(_enc.encode(text))

def split_by_token_size(text: str, target_tokens: int = 300,
                        min_tokens: int = 80) -> list:
    raw = recursive_split(text, chunk_size=target_tokens * 3)
    result, buffer = [], ""
    for piece in raw:
        merged = buffer + piece
        if token_len(merged) < min_tokens:
            # 太小的块先攒着,和后面的合并,避免产生碎块
            buffer = merged
        else:
            result.append(merged)
            buffer = ""
    if buffer:
        # 收尾的小残块并入最后一块,而不是单独留着
        if result:
            result[-1] += buffer
        else:
            result.append(buffer)
    return result

那到底设多大?没有放之四海皆准的数字,但有原则。它要和你的内容类型匹配:问答型、条目型的内容(比如 FAQ),天然就短,块可以小;叙述性强、逻辑连贯的长文,块要大一些才能保住完整的论述。它还要和你的提问类型匹配:如果用户多问"某个具体事实",小而精的块检索更准;如果多问"总结、对比"这类需要综合的问题,块要大一些。下面这张表把这些权衡列清楚:

chunk 大小的权衡

  方向      好处                  代价                  适合
  --------------------------------------------------------------
  块偏大    语义完整 上下文足      向量模糊 噪声多 费token  叙述长文 综合性问题
  块偏小    向量精准 噪声少        易缺上下文 指代悬空      FAQ 条目 事实性问题
  适中      兼顾两者              需要调试找到平衡点      多数通用场景

  常见经验起点:每块 200 到 500 token,重叠为块大小的 10%-20%;
  务必用自己的真实文档和真实问题做检索评估后再定,不要照搬。

这一节的认知是:chunk_size 不是一个有"标准答案"的参数,而是一个必须在"检索精度"和"语义完整"这两个互相拉扯的目标之间求平衡的权衡值。把它调大,你买到了语义完整,付出的是检索精度和 token;把它调小,你买到了检索精度,付出的是上下文完整性。这两个方向不可能同时拉满。所以正确的态度,不是去网上找一个"最佳 chunk size"抄过来——因为它根本不存在——而是理解这个权衡的两端各是什么,然后用你自己的真实文档、真实问题去做检索评估,看哪个值在你的场景下效果最好。能把参数选择从"抄一个数"变成"理解权衡后做评估",是 RAG 调优里很重要的一步成熟。

把按结构解析、递归切分、控制大小、加重叠这几步串起来,一份文档变成一批高质量块的完整流程,就是下面这张图:

[mermaid]
flowchart TD
A[原始文档] --> B[按结构解析 识别标题段落表格]
B --> C[递归按语义边界切分]
C --> D{块大小是否在合理区间}
D -->|过大| C
D -->|过小| E[与相邻块合并]
D -->|合适| F[给相邻块添加重叠]
E --> F
F --> G[附加标题路径等元数据]
G --> H[计算向量 写入向量库]

五、给块保留上下文:标题路径与元数据

还有一类上下文,是重叠也补不回来的——文档的层级结构。一份手册有章、有节、有小标题,某一段文字的完整含义,往往依赖它所处的标题路径。比如一段话写"默认开启,可在配置中关闭",这段话出现在"日志模块"章节下,意思就完全确定;可一旦它被切成块、脱离了原文,这一块就只剩孤零零一句"默认开启,可在配置中关闭",检索时根本对不上"日志模块怎么配置"这样的提问。解决办法是:切块时,把这一块在文档里的标题路径,作为元数据一起带上,甚至直接拼进块的文本里。

# 切分时记录每块的标题路径,作为上下文一并保留
import re

def split_with_heading_path(markdown: str, chunk_size: int) -> list:
    lines = markdown.split("\n")
    heading_stack = []      # 当前所处的标题层级栈
    blocks, buffer = [], []

    def flush():
        text = "\n".join(buffer).strip()
        if not text:
            return
        path = " > ".join(h[1] for h in heading_stack)
        for piece in recursive_split(text, chunk_size):
            blocks.append({
                "text": piece,
                "heading_path": path,          # 如 部署 > 集群模式
                # 把标题路径拼进正文,让向量也带上这层上下文
                "embed_text": f"[{path}]\n{piece}" if path else piece,
            })

    for line in lines:
        m = re.match(r"^(#+)\s+(.*)", line)
        if m:
            flush()
            buffer = []
            level = len(m.group(1))
            # 维护标题栈:弹出同级或更深的,压入当前标题
            while heading_stack and heading_stack[-1][0] >= level:
                heading_stack.pop()
            heading_stack.append((level, m.group(2).strip()))
        else:
            buffer.append(line)
    flush()
    return blocks

这里有个值得注意的细节:标题路径不只是存进元数据备查,更要拼进真正参与向量计算的那段文本里(代码里的 embed_text)。这样,这一块的向量本身就带上了"我属于部署章节下的集群模式"这个语义,用户问"集群模式怎么部署"时,检索就能对得上。元数据除了标题路径,还可以包括来源文件名、页码、章节号、更新日期等,它们在检索过滤、给用户标注答案出处时都有用。

这一节的认知是:一个块脱离原文档独立存在之后,它会丢失所有"由位置带来的隐性上下文",而切块时正是把这些隐性上下文显式地固定下来的唯一时机。在原文里,一段话属于哪一章,是它的物理位置自动赋予的,不需要写出来;可一旦它被切成一个独立的块、扔进向量库,它和原文的位置关系就彻底断了——除非你在切的时候,主动把这层关系记录下来。这就是为什么 chunking 不能只产出"一段文本",而要产出"文本 + 元数据"。切块这个动作,真正在做的事,是把一份连续的、靠位置隐含了大量上下文的文档,转换成一批离散的、必须自带上下文才能独立成立的单元。想清楚这一点,你就会在切块时格外认真地对待元数据,因为切块的那一刻,是给每个块"安上身份"的唯一机会。

六、把 chunking 做扎实,要避开的工程坑

前面五节搭出了一套像样的切分:按语义切、带重叠、控大小、附元数据。但要真正经得起生产里各种文档的考验,还有几个坑得专门讲。第一个最常见:表格和代码块绝对不能被切碎。一个表格被从中间切开,两半都不再是合法的表格,语义彻底毁掉;一段代码被切断同理。正确做法是,在切分之前先把这类"不可分割的整体"识别出来、保护起来,让切分逻辑跳过它们的内部。

# 切分前先保护表格、代码块这类不可分割的整体
import re

def protect_atomic_blocks(text: str):
    atomic, placeholders = [], {}

    def stash(match):
        key = f"__ATOMIC_{len(atomic)}__"
        atomic.append(match.group(0))
        placeholders[key] = match.group(0)
        return key      # 用占位符替换,让切分逻辑碰不到它

    # 把 Markdown 代码块整体抠出来换成占位符
    text = re.sub(r"```[\s\S]*?```", stash, text)
    # 把连续的表格行整体抠出来
    text = re.sub(r"(?:^\|.*\|$\n?)+", stash, text, flags=re.M)
    return text, placeholders

def restore_atomic(chunk: str, placeholders: dict) -> str:
    # 切完之后,把占位符还原回原始的表格、代码块
    for key, original in placeholders.items():
        chunk = chunk.replace(key, original)
    return chunk

# 流程:protect -> 正常切分 -> restore
# 表格和代码块要么完整在某一块里,要么单独成一块,绝不被切断

第二个坑,是检索之后的上下文扩展。块切得再好,单个块承载的信息也有限。一个实用技巧是:检索时按块的向量找到最相关的块,但真正喂给模型时,把这个块和它在原文里紧邻的前后块一起带上。这样,既享受了小块带来的检索精度,又给了模型足够的上下文。这要求你在切块时,给每个块记录它在原文里的序号。

# 检索命中小块,喂给模型时扩展到相邻块,兼顾精度与上下文

def expand_with_neighbors(hit_chunk_ids: list, all_chunks: list,
                          window: int = 1) -> list:
    selected = set()
    for cid in hit_chunk_ids:
        # 把命中块前后各 window 个相邻块也纳入
        for j in range(cid - window, cid + window + 1):
            if 0 <= j < len(all_chunks):
                selected.add(j)
    # 按原文顺序返回,保证上下文连贯
    return [all_chunks[j] for j in sorted(selected)]

# 检索用小块(精准),作答用扩展后的上下文(完整)
# 这就是常说的 small-to-big:小块检索,大块喂给模型

还有几个坑值得点一下。其一,块大小一定要按 token 数衡量,而不是字符数——因为下游的 embedding 模型和大模型,处理的都是 token,而且 embedding 模型本身有最大输入长度,块超了会被悄悄截断。其二,切分的质量必须被评估,不能切完就不管:准备一组真实问题和它们的标准答案,跑检索,看该召回的块有没有被召回,用这个去反向调你的 chunk_size 和重叠。其三,不同类型的文档值得用不同的切分策略,代码文档、法律条文、对话记录,各有各的天然结构,不必强求一套切法走天下。这几个坑串起来是同一个意思:chunking 不是一段"写一次就定死"的工具代码,它是 RAG 系统里需要持续观察、评估、按文档类型调整的一个环节。它的好坏没法只靠读代码判断,必须靠"拿真实问题去检索、看召回准不准"来检验。把切分当成一个要被评估、被迭代的对象,而不是一个一次性的预处理脚本,RAG 的效果才有持续变好的可能。

关键概念速查

概念 说明
chunking 把文档切成块的过程,块是 RAG 检索与召回的最小单位
固定大小切分 按固定字数机械切分,无视语义结构,易把句子段落拦腰切断
语义边界 段落、句子之间的天然缝隙,下刀于此两侧才都完整
递归字符切分 按分隔符从大到小递归切分,优先在大的语义边界下刀
chunk overlap 相邻块之间的重叠内容,补偿被边界切断的跨块语义联系
chunk size 块的大小,在检索精度与语义完整之间权衡,应按 token 计量
标题路径 块在文档层级中的位置,作为元数据保留并拼入向量文本
元数据 块自带的来源、章节、序号等信息,用于过滤、扩展、标注出处
原子块保护 表格、代码块等不可分割整体,切分前识别保护,避免被切碎
small-to-big 用小块检索保精度,作答时扩展到相邻块保上下文

避坑清单

  1. 不要把切块当成无关紧要的预处理:块的质量给整个 RAG 系统的效果设了上限。
  2. 不要按固定字数切:那把尺子对语义结构无知,必然把句子、段落拦腰切断。
  3. 不要让切点无视文档结构:用递归切分,优先在段落、句子等语义边界下刀。
  4. 不要让相邻块完全不重叠:切分必然切断跨块联系,用重叠补偿,常取块的 10%-20%。
  5. 不要追求一个"标准 chunk size":它是检索精度与语义完整的权衡,需评估后定。
  6. 不要按字符数衡量块大小:下游按 token 处理,embedding 模型超长会被截断。
  7. 不要丢掉标题路径:块脱离原文会丢失位置上下文,切块时是固定它的唯一时机。
  8. 不要只产出纯文本:块要带元数据,来源、章节、序号在过滤和扩展时都有用。
  9. 不要把表格、代码块切碎:切分前识别这类原子整体并保护,绝不从中间切开。
  10. 不要切完就不管:用真实问题做检索评估,据此反向调切分参数和策略。

总结

回头看第一版那个"按字数均匀切"的 RAG,它的错误很典型。它不在某一行代码,而在一个对 chunking 的根本误解:以为切块只是个机械的、把长文档分小的预处理动作,切得整齐就行。真相是,块是检索和召回的最小单位,块的质量就是喂给模型的原料的质量,它给整个系统的效果设了一个上限。固定字数切分,用一把对语义结构一无所知的尺子,切出了一堆残缺、混杂、缺上下文的块,后面的检索再准、模型再强,也救不回这堆坏原料。

而把 chunking 做对,工程量并不小。它不是调一个 chunk_size 参数那么简单,而是要按语义边界递归地切、给相邻块加重叠补偿、在检索精度和语义完整之间权衡块的大小、把标题路径和元数据一并保留下来,还要保护表格代码这类原子块、做检索后的上下文扩展、按 token 计量、并持续用真实问题去评估。一套能产出高质量块的切分,是这些环节一个不少地拼起来的。

这件事其实很像把一本厚书拆成一沓资料卡片,做成一个卡片检索盒。拆得好的人,会顺着书的章节、段落来拆,每张卡片正好是一个完整的知识点;还会在卡片角上标好"这是第几章第几节"(标题路径),会让相邻卡片的内容稍微搭一点边(重叠),会把书里那张完整的表格单独抄成一张卡而不是撕成两半(原子块保护)。拆得差的人,拿尺子量着每五厘米裁一刀,结果一句话裁成两张卡,一张图裁得七零八落。日后要查资料,前一种盒子一翻就能找到完整答案,后一种盒子翻出来的全是断章残句。RAG 里的 chunking,就是在做"拆书做卡片"这件事,拆得好不好,决定了这个盒子好不好用。

这类问题还有一个共同的麻烦:它在本地、在小数据上很难暴露。你自己拿一两份格式规整的文档、提几个简单问题去测,固定字数切看起来也能答对——因为你的文档恰好结构简单,你的问题恰好没踩到那些被切碎的块。真正会把切分的裂缝撑开的,是上线后那些格式千奇百怪的真实文档,和用户那些刁钻、需要精确定位的真实提问。所以如果你正在做一个 RAG 系统,别等用户反馈"它答不上手册里明明写着的东西",才回头怀疑切分。在写下切块的第一行代码时,就把它当成决定系统效果上限的关键一环来认真对待——按语义切、带上下文、可评估——这是这篇文章最想留给你的一句话。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

限流算法完全指南:从一次"限了每分钟一万却涌进两万"看懂固定窗口、滑动窗口、漏桶与令牌桶

2026-5-22 20:14:47

技术教程

数据库索引失效完全指南:从一次"明明建了索引查询却还是全表扫描"看懂索引为什么没被用上

2026-5-22 20:27:30

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索