欢迎光临
我们一直在努力

RAG 分块别再无脑切 512 了:我用父子分块把“答案差一口气”的投诉压到 1/3

一、线上问题:答案“就差一口气”,但每次差的地方不一样
先说一个很具体的现象。用户问“XX-200 型号换型时 M8 螺栓的扭矩值是多少”,我们的 RAG 系统检索到了正确的设备手册章节,但返回的 chunk 里只有“拧紧 M8 螺栓至规定扭矩”,没有那张扭矩对照表。LLM 只能回答“请参考手册中的扭矩参数”,用户当场炸了。

另一类反馈更隐蔽:跨境退款政策,条款前半句在 chunk A,后半句的“仅限于未发货订单”在 chunk B。向量检索只召回了 A,LLM 给出的答案缺了关键限定条件,看起来对,实际是错的。

排查后发现不是 embedding 不行,也不是 Rerank 没做好,根因在切分阶段。我们当时用全局固定的 RecursiveCharacterTextSplitter(chunk_size=500),所有文档一视同仁。但设备手册的语义边界是标题层级和表格,退款政策的语义边界是条款编号和条件从句,固定字符数的边界和作者写下的语义边界几乎不可能重合。结果是条件与结论被分到不同块,表格被从中间劈开,块内混入无关条款。

这个问题不是调大 Top-K 能解决的。调大只会把更多碎片塞进 Prompt,噪声比例同步上升。问题的本质是:召回效果最好的块,和回答效果最好的块,往往不是同一个块。 小块命中率高、噪声低,但缺少回答所需的完整条件;大块信息完整,但向量被稀释、匹配变差。

分块不是 ingestion 阶段的一个孤立步骤,它直接定义了检索系统的“语义单元”。切错了,后面 Rerank 再强、模型再好,都是在补救一个结构性缺陷。

二、整体方案:策略路由 + 父子分块 + 结构元数据
最终落地的架构不是“换一个更好的分块器”,而是一套组合方案。

2.1 文字版架构
text
原始文档 (PDF / Markdown / HTML)
│
▼
┌────────────────────────────────────┐
│ 结构解析层 │
│ 识别标题层级、列表、表格边界、条款号 │
│ → 输出带 section_path 的段落树 │
└────────────────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ 策略路由层 │
│ 文档类型 + 语言 + 结构信号 │
│ → 结构切分 / 递归切分 / 语义补充 │
└────────────────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ 父子分块层 │
│ 父块 (512~1024 token) 保留完整上下文 │
│ 子块 (128~256 token) 用于向量召回 │
│ parent_id 关联 │
└────────────────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ 索引层 │
│ 只索引子块向量 │
│ docstore 存父块全文 + 元数据 │
└────────────────────────────────────┘
│
▼
检索:子块精准召回 → 按 parent_id 回填父块 → 进入 Prompt
核心决策有三个:第一,不追求“一种切分打天下”,而是按文档类型路由;第二,用父子分块分离“召回单元”和“生成单元”;第三,把 section_path、clause_no、token_count、strategy 这些结构元数据写入每个 chunk,检索时可以按元数据过滤,排查时可以追溯。

三、核心模块拆解
3.1 结构感知的递归切分:先尊重作者写的边界
对设备手册、SOP、条款类文档,标题层级和条款编号就是天然语义边界。固定字符切分的问题在于它的边界由计数决定,和作者的写作单元无关。我们的做法是优先按结构切,结构单元超长时再递归细分。

下面这段基于 LangChain,对 Markdown 和带标题的文本可以直接运行:

python
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document

按结构优先级设置分隔符:先标题层级,再段落,再句子

关键取舍:把 "\\n## " 放在 “\\n\\n” 前面,保证不会先把章节边界切碎

structural_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=80, # 约 10% overlap,防止跨段指代断裂
separators=[
"\\n## ", # 二级标题
"\\n### ", # 三级标题
"\\n#### ",
“\\n\\n”, # 段落
“\\n”,
“。”, “!”, “?”, # 中文句子边界
" ",
],
add_start_index=True, # 保留起始位置,便于回溯源文档
)

docs = structural_splitter.split_documents(raw_documents)

注入结构元数据

for doc in docs:
# 此处需结合你的真实项目补充:section_path 应从解析层继承
# 这里示意从文本中提取最近的标题
doc.metadata[“section_path”] = extract_section_path(doc.page_content)
doc.metadata[“doc_type”] = doc.metadata.get(“doc_type”, “manual”)
doc.metadata[“token_count”] = len(doc.page_content) // 2 # 粗略估算
一个生产细节:表格必须整体成块,不参与递归细分。我们最早允许表格参与字符二分,结果一张扭矩对照表被切成两半,召回回来只有半截列名。后来在解析层把表格识别出来,强制作为一个完整 chunk,问题消失。同理,代码块和 FAQ 的问答对也不应该被递归切分器拆散。

3.2 父子分块:用两条数据通路解决目标冲突
父子分块的核心洞察很简单:子块负责召回,父块负责生成。子块小,向量语义凝练,命中率高;命中后不直接把子块送给 LLM,而是按 parent_id 回填父块,让生成阶段拿到完整的条件、表格和上下文。

LangChain 的 ParentDocumentRetriever 已经把这套逻辑封装好了,可以直接运行:

python
from langchain.retrievers import ParentDocumentRetriever
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import OpenAIEmbeddings
from langchain.storage import InMemoryStore

父块:保留完整章节上下文,用于生成

parent_splitter = RecursiveCharacterTextSplitter(
chunk_size=1600,
chunk_overlap=100,
separators=["\\n## ", "\\n### ", “\\n\\n”, “\\n”, “。”],
)

子块:小粒度,用于向量召回

child_splitter = RecursiveCharacterTextSplitter(
chunk_size=300,
chunk_overlap=30,
separators=[“\\n\\n”, “\\n”, “。”, " "],
)

vectorstore = Chroma(embedding_function=OpenAIEmbeddings())
store = InMemoryStore() # 生产环境换成 Redis / DocStore

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

retriever.add_documents(raw_documents)

检索时:内部先用 child 做向量匹配,再回填 parent

results = retriever.invoke(“XX-200 换型 M8 螺栓扭矩”)
for doc in results:
# doc 是父块,包含完整的表格和上下文
print(doc.page_content[:500])
关键取舍:父块不能过大。父块超过 1600 token 后,虽然“信息完整”,但会挤占 Context Budget,而且如果父块跨越了太多无关章节,LLM 的注意力会被稀释。我们的经验是父块控制在 1200~2000 token,子块 200~400 token,child 与 parent 的比例大约 1:4 到 1:6。此处需结合你的真实项目补充:如果你的文档表格特别多,父块需要再大一些才能保住表头;如果全是短 FAQ,父子分块可能带来的收益有限,直接用句子窗口就够了。

3.3 语义分块:作为补充路径,而不是默认路径
这里要讲一个反直觉的结论。2026 年的多项独立评测给出了相似的方向:语义分块并不一致优于精心配置的固定/递归分块。一项在 SQuALITY 长文档上的对照实验发现,语义分割“有竞争力,但并未稳定超过 512–1024 token 的固定窗口”。Vectara 的 2026 研究更直接:递归 512-token 分块在准确率上以 69% 超过语义分块的 54%。另一项系统分析指出,句子分块是最具成本效益的方法,在约 5k token 以内可以匹配语义分块的效果,而语义分块的额外 embedding 开销和延迟并不总能换来对等收益。

语义分块的真实风险是产出过碎的 40 token 级别碎片,这些碎片向量很“纯”,但缺少回答所需的限定条件。所以我们的策略是:递归切分兜底,语义分块只在结构信号不足的连续叙述型文档上作为补充,而且必须配合父子分块使用,让碎片只承担召回职责,不直接进入生成。

如果要用语义分块,LlamaIndex 的 SemanticSplitterNodeParser 可以直接跑:

python
from llama_index.core.node_parser import SemanticSplitterNodeParser
from llama_index.embeddings.huggingface import HuggingFaceEmbedding

embed_model = HuggingFaceEmbedding(model_name=“BAAI/bge-small-zh-v1.5”)

semantic_parser = SemanticSplitterNodeParser(
embed_model=embed_model,
buffer_size=1, # 逐句计算相似度,1 适合短段落
breakpoint_percentile_threshold=95, # 阈值越高,切得越少;95 是保守起点
include_metadata=True,
)

semantic_nodes = semantic_parser.get_nodes_from_documents(narrative_docs)
breakpoint_percentile_threshold 的调参方向要记住:调低会生成更多节点、更碎;调高会切得更少、块更大。如果发现召回回来的块“太短、缺条件”,首先往上调这个值,或者直接回退到递归切分。

四、关键工程问题与解决方案
4.1 成本:语义分块的索引账单不是小数
语义分块需要对每个句子做 embedding 来计算相邻相似度,索引阶段的 embedding 调用量大约是递归切分的 3~5 倍。对于百万级 chunk 的知识库,如果按 token 计费的商业 embedding API 来做,这笔成本会在迁移时集中体现。我们的处理是:索引阶段的语义相似度计算用本地小模型(如 bge-small),生成最终检索向量时用主模型。两套向量不混用,小模型只负责“找边界”,主模型负责“做检索”。

4.2 延迟:分块本身不直接进在线链路,但间接影响检索延迟
分块是离线过程,不占在线延迟。但块的大小会间接影响在线检索:块越大,向量索引密度越低,检索延迟反而可能下降;块越小、数量越多,索引密度高,同样的 Top-K 需要扫描更多候选。父子分块在这里有一个额外好处:只索引子块向量,索引体积小;父块存在 docstore 里,回填是一次 KV 查询,通常比向量检索本身还快。

4.3 稳定性:分块策略必须可配置、可回滚
我们把切分预算(chunk_size、overlap、父子比例、是否启用语义分块)从代码常量迁到了配置中心,按租户和文档类型灰度。这样做的好处是:某个租户的文档结构变了导致召回下降时,不用发版,改配置就能回滚到上一版策略。同时每个 chunk 写入 strategy 和 version 元数据,出了问题可以按版本切分排查。

五、效果评估:具体数字
评估集是 300 条从线上 query 日志采样、人工标注 gold chunk 的问答对,覆盖设备手册、政策条款、FAQ 三类文档。对比的是“全局固定 500 字符递归切分”基线和“策略路由 + 父子分块”。

指标固定 500 字符递归策略路由 + 父子分块变化
Recall@10(子块级别)0.740.86+12pp
答案完整性(人工评分,1–5)3.14.3+1.2
“缺限定条件”类投诉占比18%6%-12pp
平均 Prompt token 数1,8501,420-23%
索引 embedding 调用量1×1.3×(语义补充路径)+30%
Recall@10 的提升主要来自结构感知切分把表格和条款完整成块,以及父子回填让“正确子块命中”能转化为“正确答案上下文”。Prompt token 数反而下降了,因为父块回填替代了“多召回几条碎片凑上下文”的策略。这和公开案例的方向一致:有团队用结构感知的分块把召回率从 62% 拉到 89%,忠实度从 0.71 到 0.93;结构块在混合检索下比 512 字符窗口在 Qasper 上高出 23 个百分点。

需要诚实说的是:Recall@10 的增益里,有多少来自父子分块、有多少来自结构元数据过滤,我们没有完全拆开。此处需结合你的真实项目补充:如果你的团队要做归因,建议做消融实验:分别关掉父子回填和元数据过滤,各跑一遍评估集。

六、我踩过的坑
坑一:把语义分块当成“更高级所以更好”。 我们最初在一批政策文档上全量启用语义分块,结果索引成本涨了 4 倍,Recall 反而比递归 512 低了 3 个百分点。切出来的块太碎,很多 40~60 token 的碎片缺少主语和限定条件,向量相似度很高,但回填父块后 LLM 拿到的上下文里噪声比例上升。语义分块是补充工具,不是升级替代。

坑二:overlap 设成 0,跨段指代断裂。 “步骤 3 接步骤 2”被切到两个块,模型拼不出连续动作。设 10% overlap(约 80~120 字)后明显好转。但另一项系统分析也指出,overlap 在很多场景下“没有可测量的收益,反而增加索引成本”。我们的结论是:overlap 对步骤类、条款类文档有效,对独立 FAQ 类文档可以关掉,不要全局一刀切。

坑三:元数据当空气。 早期 chunk 只存 doc_id,检索时区分不了“XX-200”和“XX-500”,也追溯不到具体条款。后来每个 chunk 注入 section_path、clause_no、device_model、strategy、version,检索时可以用元数据过滤把错误型号直接排除,排查时能顺着 metadata 回到原文位置。

七、团队协作与落地过程
第一步,用投诉数据说服团队“分块值得单独做”。 我把“缺限定条件”和“表格断裂”两类投诉各抽了 20 条,逐条定位到 chunk 边界,做成 before/after 对比。产品和客服一看就明白问题不在模型,而在切分。用投诉的原始文本对齐,比讲 token 和 embedding 有效得多。

第二步,灰度按租户走,不按流量走。 分块策略是文档级属性,同一个租户的文档应该走同一套策略。我们按租户 ID 灰度,先切了 3 个设备手册为主的租户,观察一周的召回率和投诉率,再逐步扩到政策条款类租户。如果按 user_id 哈希灰度,同一个知识库会在一段时间内混用两种切分策略,评估噪声会掩盖真实信号。

第三步,监控固化。 上线后盯三个数:按 strategy 分组的 Recall@10 周环比、父子回填后的 Prompt token 分布、配置中心的分块策略变更记录与回滚次数。分块的问题最怕“静默变差”,没有按策略分组的评估,你根本不知道是哪类文档在拖后腿。

面试延伸:RAG 分块主题的高频追问
Q1:chunk size 设多少合适?
不存在通用数字。chunk 大小由知识结构、问题粒度、embedding 特性、召回策略和 Context Budget 共同决定。factoid 类查询 256–512 token,分析/多跳 512–1024,整节理解 1024–2048。更工程的说法是:先做评估集,用 Recall@K 曲线选,不要背默认值。

Q2:语义分块一定比固定分块好吗?
不一定。2026 年多项评测显示语义分块“有竞争力但未稳定超越”精心配置的递归 512–1024 分块,而语义分块的索引成本更高、可能产出过碎碎片。递归兜底、语义补充、父子分块连接召回与生成,是更稳的工程组合。

Q3:父子分块解决了什么问题?
它承认“召回效果最好的块”和“回答效果最好的块”目标冲突,用两条数据通路分别满足:子块小、向量纯、命中准;父块大、上下文完整、生成质量高。代价是索引多一层 KV 存储和 parent_id 关联,以及父块大小需要调参。

Q4:表格和代码块怎么切?
不切。表格整体成块,保留表头;代码块按函数/逻辑边界切,不按字符数切。结构单元的完整性比块大小的均匀性重要得多。

总结
分块不是“调一个 chunk_size 参数”的事,它是 RAG 系统定义“什么是语义单元”的决策。固定字符切分的根本缺陷不是数字不对,是边界和作者写下的结构边界几乎不重合。我最终的方案是策略路由 + 父子分块 + 结构元数据:结构文档按标题/条款切,连续叙述用递归兜底、语义作补充,子块召回、父块生成,每个 chunk 带可追溯的元数据和可回滚的策略版本。

赞(0)
未经允许不得转载:171主机测评 » RAG 分块别再无脑切 512 了:我用父子分块把“答案差一口气”的投诉压到 1/3
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址