父子分块:让检索命中小片段、喂给模型大上下文
父子分块让检索命中小片段、喂给模型大上下文一个合同问答系统在验收时暴露了尴尬的一幕:用户问违约金上限是多少,向量库确实召回了包含违约金字样的那一段,可模型给出的数字张冠李戴——它把另一份附件里的赔偿条款套了进来。排查下来,命中的那个 chunk 只有两三句话,精准地匹配到了违约金这个词,却把限定这条金额的前置条件(适用范围、生效前提)全丢在了相邻的、没被召回的片段里。这不是模型笨,是切块策略在检索和生成两个环节之间做了一个无法两全的妥协。现象:检索命中了,答案却是错的这类问题有很强的迷惑性,因为从检索指标看一切正常:相关片段确实排在前列,召回率也不难看。真正的失效发生在把片段拼进 prompt 之后。块切得越小,嵌入向量越纯。一句话对应的向量,语义指向单一,查询稍微沾边就能精准命中,检索精度高。但一句话往往不完整——代词指代的对象、金额生效的前提、函数调用的上下文,都散落在前后文里。模型拿到孤立的一句,只能靠猜或者靠自己的先验去补,补错了就是幻觉。反过来把块切大,比如一整节文档塞成一个 chunk,上下文是完整了,可嵌入向量被稀释了。据多篇 RAG 工程实践文章的一致观察,过长文本的 embedding 会语义发散:一段里同时讲了三四件事,向量成了这些主题的平均,任何一个子话题的查询都只能拿到中等偏低的相似度,精准查询反而排不上来。于是要么召回不到,要么召回一堆半相关的大块,把 context window 撑爆还稀释了真正有用的信息。问题的根子在于:切块这一个动作,被迫同时服务两个目标不一致的环节。检索环节要的是语义聚焦、便于匹配,生成环节要的是上下文完整、便于推理。用同一个块尺寸去满足两者,必然是各让一步的次优解。原理:检索粒度和合成粒度本就不该相同父子分块(parent-child chunking,也叫 small-to-big retrieval、parent document retrieval)的核心思路,是把这两个粒度彻底解耦:用小块去检索,用大块去合成。具体做法是建立一层父子映射。原始文档先切成较大的父块(parent),父块再进一步切成更小的子块(child)。只对子块做嵌入、进向量库;父块不参与向量检索,只按 ID 存在一个普通的文档存储里。查询时,向量检索在子块空间里进行——因为子块语义聚焦,命中精准;命中若干子块后,不把子块本身喂给模型,而是顺着子块记录的 parent_id 找到它所属的父块,把父块送进 prompt。这样一来,检索享受小块的精度,生成享受大块的上下文,两个环节各取所需,不再互相拖累。据 LlamaIndex 与 LangChain 的官方文档,这一模式在二者中分别以 RecursiveRetriever / 节点引用检索和 ParentDocumentRetriever 的形式提供;一个被反复引用的经验尺寸区间是子块约 200 token、父块约 1500~2000 token(具体数值属示例配置,应按语料和模型上下文预算调整)。如果你正在评估自己的 RAG 系统是不是踩了这个坑,可以对照一份面向上线前的检索质量与幻觉自检清单逐项过一遍——检索命中却答错、答案里出现语料中不存在的数字、同一问题换个问法结果就飘,这几条往往都指向检索粒度和合成粒度没有分开。理清这一点,再往下看落地就顺了。落地:父子索引怎么建、怎么查落地分两个阶段:离线建索引,在线查。建索引时要同时写两个存储——向量库存子块的嵌入,文档库(docstore,可以是内存字典、Redis 或任意 KV)存父块原文。关键是每个子块的 metadata 里必须带上它的 parent_id。fromdataclassesimportdataclass,fielddataclassclassChunk:id:strtext:strparent_id:str|NoneNonemetadata:dictfield(default_factorydict)defbuild_parent_child_index(doc_id:str,text:str,parent_splitter,# 切父块:约 1500~2000 tokenchild_splitter,# 切子块:约 200 tokenembed_fn,# 文本 - 向量vector_store,# 只存子块向量doc_store,# 只存父块原文(KV))-None:forp_idx,parent_textinenumerate(parent_splitter(text)):parent_idf{doc_id}::p{p_idx}doc_store.put(parent_id,parent_text)# 父块不进向量库forc_idx,child_textinenumerate(child_splitter(parent_text)):childChunk(idf{parent_id}::c{c_idx},textchild_text,parent_idparent_id,# 关键:回指父块)vector_store.add(vectorembed_fn(child.text),# 只嵌入子块payload{parent_id:child.parent_id,text:child.text},)查询阶段的要点,是在拿到子块命中后做父块去重。因为一个父块通常切出多个子块,一次查询很可能召回同属一个父块的好几个子块,如果不去重,同一段父块会重复塞进 prompt,既浪费 token 又干扰模型。defretrieve(query:str,embed_fn,vector_store,doc_store,top_k_child8,max_parent3):hitsvector_store.search(embed_fn(query),top_ktop_k_child)seen:set[str]set()parents:list[str][]forhinhits:# 命中已按相似度降序pidh.payload[parent_id]ifpidinseen:continue# 去重:同一父块只取一次seen.add(pid)parents.append(doc_store.get(pid))iflen(parents)max_parent:breakreturnparents# 喂给 LLM 的是父块,不是子块几种常见变体值得对照选择:变体检索单元送入模型的单元适用场景父子分块子块(小)父块(大)结构化长文档、条款/章节明确句子窗口检索单句命中句 前后 N 句上下文连续、章节边界弱的散文自动合并检索叶子块命中足够多时合并回上层块多层级目录、需动态决定粒度朴素固定切块同一块同一块短文档、检索与合成粒度天然一致边界与取舍父子分块不是免费的。它把每个结果更完整换成了同样 token 预算下结果条数更少:父块大,一次能塞进上下文的父块数量就有限,如果一个问题需要跨越很多不相邻片段做综合,少数几个大父块未必覆盖得全,这时反而不如多召回一些中等块。父块尺寸是最需要调的旋钮。父块太大会把无关内容一起带进来,重新稀释信噪比,等于把大块检索的老问题搬到了生成端;太小又退化成普通切块,失去父子分块的意义。它和模型上下文预算、单问题所需的跨片段数量三者需要一起权衡,没有普适最优值。存储和一致性也有额外成本。要维护向量库和文档库两套存储,父块更新时子块嵌入要跟着重建,删除文档要级联清理子块——parent_id 这条引用一旦失配,查询就会拿到空父块或错父块。工程上建议把 parent_id 作为强约束,建索引和删除都走同一套映射逻辑,并在检索后校验父块非空。还有一个容易忽略的边界:父子分块解决的是粒度错配,不解决切错位置。如果父块的切分边界本身就把一条完整语义(比如一个跨段落的条款)拦腰截断,那么无论父块多大,送进去的都是残缺的。父块切分应尽量沿结构边界(标题、条目、段落)走,而不是纯按 token 数硬切。技术结论父子分块的价值,不在于某种更聪明的嵌入或更强的模型,而在于承认了一个朴素事实:检索要的粒度和生成要的粒度天生不同,用一个块尺寸同时服务两者必然次优。把子块用于匹配、父块用于合成,是对这个矛盾的直接拆解。落地时真正决定效果的,是三个工程细节而非算法:父块尺寸与模型上下文预算的匹配、命中后的父块去重、以及父块沿结构边界切分而非按 token 硬切。把这三点做扎实,同样的语料和同样的模型,检索命中却答错的比例通常能显著下降。而当单个问题需要跨大量不相邻片段综合时,要清醒地知道父子分块不是银弹,该退回多块召回或换用自动合并策略。选型的前提,永远是先看清自己的问题是命中不准还是上下文不全——父子分块专治后者引发的前者。