分块的艺术:如何把文档切成 AI 爱吃的尺寸

下午三点,老李端着保温杯在工位间溜达,路过小王时停住了。

“小王,你那个 RAG 系统不太行啊。”老李的语气带着“我就知道”的味道,“我昨天把产品手册丢进去了,问‘Postgres 连接池怎么配置’,你猜它回我什么?它说‘max_connections 参数在生产环境建议设置为’——然后就没了。话说到一半,断那儿了。”

小王把嘴里的咖啡咽下去:“老李,文档你是怎么切的?”

“切?就按你说的,把文档拆成一块一块的嘛。我写了段代码,每 512 个字符切一刀,整整齐齐。”老李语气里满是自信,“我们以前处理日志就是这么干的,固定大小,简单可靠。”

“老李你想啊——”

“我想什么想?”

“你烤羊肉串的时候,是按厘米切的吗?”

老李举着保温杯的手悬在半空,表情像被人问住了。

“烤串你得顺着肉的纹理切,肥瘦相间,一块太厚不入味,一块太薄烤糊。切文档也是这个道理——你按 512 字符硬切,正好把‘建议设置为 200’和下一句‘同时配合 pgBouncer 使用’切成了两块。RAG 检索到了上半块,没检索到下半块,大模型只能照着半句话回答。”

老李若有所思:“那你的意思是,我这刀法不对?”


第一刀:固定大小分块——简单但粗暴

“固定大小切块不是不能用,”小王把椅子滑到老李旁边,“但它是所有策略里最粗暴的一种。我看看你切的——”

小王打开老李的代码,几行简洁的 Python 躺在那里:

python
# 老李的刀法:一刀切,512 字符一块
def old_li_chunker(text, chunk_size=512):
    chunks = []
    for i in range(0, len(text), chunk_size):
        chunks.append(text[i:i + chunk_size])
    return chunks

doc = "Postgres 连接池在生产环境建议设置 max_connections 为 200," \
      "同时务必配合 pgBouncer 使用以避免连接数暴涨。"

chunks = old_li_chunker(doc, chunk_size=30)  # 故意用小尺寸演示问题
for i, c in enumerate(chunks):
    print(f"块{i}: {c}")
# 输出:
# 块0: Postgres 连接池在生产环境建议设置 max_connect
# 块1: ions 为 200,同时务必配合 pgBouncer 使用以避免连
# 块2: 接数暴涨。

老李看着输出,脸有点黑:“第一块把 max_connections 给腰斩了,max_connect 这谁看得懂?”

“对。一句话被拦腰截断,向量化之后,max_connectmax_connections 的语义相似度差了一大截。用户搜‘连接池配置’,这块大概率匹配不上。”

“那你说怎么切?”

“看肉的纹理。”小王在屏幕上画了几个框图。

图:同样的文档,固定大小切出断句残词,语义切分保持话题完整


第二刀:语义分块——顺着话题的纹理

“好的切分,要让每一个块都是一个相对完整的‘话题单元’。”小王打开一个新文件,“最简单的语义切分就是按段落来。写文档的人不是乱写的,一个自然段通常讲一个具体的小问题。”

python
# 小王的第一版改进:按自然段落切
def paragraph_chunker(text):
    # 按双换行分段,保护每个段落的完整性
    paragraphs = text.split("\n\n")
    return [p.strip() for p in paragraphs if p.strip()]

# 但长段落还是太长了——需要第二刀
def semantic_chunker(text, max_chars=1000):
    """按段落切,超长的再按句号分"""
    paragraphs = text.split("\n\n")
    chunks = []
    for para in paragraphs:
        if len(para) <= max_chars:
            chunks.append(para.strip())
        else:
            # 长段落按句子进一步切
            sentences = para.replace("。", "。||").split("||")
            current = ""
            for sent in sentences:
                if len(current) + len(sent) <= max_chars:
                    current += sent
                else:
                    chunks.append(current.strip())
                    current = sent
            if current:
                chunks.append(current.strip())
    return chunks

“这个思路好理解,”老李点点头,“但小王你想过没有,有些文档根本没有段落结构——聊天记录、会议纪要、客服对话,全是一行一行的,你怎么找‘话题边界’?”

“问到点了。这时候就要上更聪明的切分方式。”


第三刀:递归分块——像剥洋葱一样

“LangChain 里有一个 RecursiveCharacterTextSplitter,它的逻辑特别像剥洋葱。”

老李又皱眉了:“怎么你们年轻人都爱用厨房打比方?”

“因为它确实像。你拿到一个洋葱,先试着按整个切,太大了;那就按层剥,还是大;按瓣剥,刚刚好。递归分块器的逻辑就是:先用双换行符切,如果块还是太大,换单换行符切,再大换句号,再大换空格,实在不行才按字符硬切。”

小王调出了 LangChain 的实现:

python
from langchain.text_splitter import RecursiveCharacterTextSplitter

# 这哥们儿自带"剥洋葱"逻辑
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,        # 目标块大小(字符数)
    chunk_overlap=50,      # 块与块之间重叠 50 字符
    separators=["\n\n", "\n", "。", " ", ""]  # 洋葱的层
)

doc = "## 连接池配置\n\nmax_connections = 200\n建议配合 pgBouncer。\n\n## 注意事项\n..."

chunks = splitter.split_text(doc)
for i, c in enumerate(chunks):
    print(f"--- 块 {i} (长度 {len(c)}) ---\n{c}\n")
# 输出:每块在 500 字符内,且在句子/段落边界处断开

老李看着代码,保温杯在手里慢慢转:“这个 separators 列表有意思,优先级从高到低,尽量在语义边界切。”

“对!而且你注意到那个 chunk_overlap=50 了吗?这就是烤串里的‘肥瘦相间’。”


为什么需要重叠?——像串肉的签子

“重叠又是啥讲究?这不就重复了吗?”老李指着那个参数。

“老李,你想想烤串为什么肥瘦要相间?纯瘦的太柴,纯肥的太腻。一块纯瘦的羊肉旁边配一点肥的,串在一起才好吃。但更重要的是——你得有一根签子把相邻的肉连在一起。”

小王在玻璃上画了一排方块:

“假设一句话是‘max_connections 建议设为 200,同时配合 pgBouncer’。这句话的意思,分布在两个相邻的块里。如果两块完全不重叠,RAG 检索时只命中了一块,大模型看到的是残缺的上下文。但如果两块之间重叠了半句话,RAG 无论命中哪一块,大模型都能看到完整的因果逻辑。”

python
# 重叠的作用:避免语义在边界处丢失
chunk_a = "在生产环境中,max_connections 建议设置为 200,"
chunk_b = "max_connections 建议设置为 200,同时务必配合 pgBouncer 使用。"
#                  ^^^^^^^^^^^^^^^^^^^^^^^^ 重叠部分保证了语义完整

# 检索"连接池怎么配",可能命中 chunk_b
# 如果 chunk_b 只是 "同时务必配合 pgBouncer 使用",大模型会一头雾水

“所以重叠不是浪费,是给语义边界的断点上保险?”老李问。

“精准。通常 overlap 设为 chunk_size 的 10%~20%。太小了不管用,太大了浪费存储。”


分块策略的选择框架

“那你说说,我到底该用多大的块?什么时候用哪种策略?”老李掏出了他的小本本,这个动作说明他认真了。

“三个问题就能决定。”小王伸出手指。

“第一,文档是什么类型?技术文档段落整齐,语义分块就很好。聊天记录没有段落结构,递归分块更稳。代码文件?那得用 AST 分块,按函数/类来切。”

“第二,你的嵌入模型能处理多长的文本?text-embedding-3-small 上限是 8192 token,但实际最优效果在 512 token 左右。超出最优长度,语义信号会被稀释,就像一锅汤里放了太多水。”

“第三,下游任务需要多大的上下文?如果是 FAQ 式的短问答,小块就够了。如果是‘总结这篇文档的核心观点’,块太小了就看不到全貌。”

老李在本子上刷刷写着,嘴里念叨:“文档类型、模型上限、任务需求——我记下了。”

“有个经验值:短问答用 256~512 token,技术文档用 512~1024 token,需要完整推理的长文档用 1024~2048 token。但不要超过嵌入模型的最优长度,否则效果反而下降。”

图:分块策略选择流程——从文档特征出发,选择最合适的刀法


老李合上小本本,沉默了一会儿。

“所以我不是 RAG 不好用,是我切肉的方式不对。”

“可以这么说。”小王没客气。

“那我把产品手册重新切一遍,你给我看看那个递归分块的参数。”老李站起来,走了两步又回头,“话说回来,烤羊肉串这个比喻你哪学的?你平时加班都吃这个?”

“楼下那个新疆馆子,老板每次都给我多撒一把孜然。老李你要不要下次一起去?顺便聊聊 token 化的部分——那又是另一门切肉的手艺了。”

老李摆摆手,保温杯里的枸杞晃了晃:“下不为例啊。一个分块搞出这么多花样,你们年轻人就是爱折腾。”

但他走到工位门口时,又探头回来:“那什么,你说的 token 化,是跟字数还不一样对吧?你再给我讲讲这个……”

小王笑着拉开椅子,下午的日光正好打在键盘上。

检索不止余弦相似度:高级检索策略大揭秘
向量数据库:让嵌入们去开派对的地方