给 RAG 选切块粒度,是每个落地团队都会撞上的第一堵墙。切得细,向量语义聚焦,检索命中率漂亮;但喂给模型的片段缺头少尾,模型只能靠猜补全上下文。切得粗,段落完整、答案有据可依;可一块七八百字塞进一个向量里,多个主题互相稀释,相似度分数变得迟钝,该命中的检索不中。本系列前面的《RAG 切块策略实战》讲的是「怎么把块切好」,这篇讲的是另一个思路:把「用来检索的块」和「用来生成的块」拆开——这就是父子检索(small-to-big retrieval,也叫 parent-document retrieval)。

两难的根源:一个向量同时服务两个诉求
先把问题拆开看。向量检索做的事,是把用户查询映射到语料库里「语义最相关」的位置,它的关键词是匹配精度——块越小、主题越集中,嵌入向量所承载的语义就越纯粹,和查询向量的夹角也就越有区分度。检索研究里常说的「块大小与召回质量的倒 U 形关系」,左半边讲的就是这个:块太大时相似度被稀释,召回先崩。
但生成端诉求恰好相反。大模型回答问题靠的是上下文里的证据完整性:一个只说「退款应在 30 天内申请」的句子,缺了适用范围、例外条款、审批流程,模型要么答一半,要么脑补另一半。生成质量通常随上下文块增大而先升后降——升是因为证据变全了,降是因为块里开始混入无关内容,噪声和注意力稀释(《上下文组装》一篇会展开)抵过了信息增益。
一句话总结这对矛盾:检索要小,生成要大。传统切块被迫用一个粒度同时满足两个诉求,父子检索的解法就是不再强求:索引用小块保匹配精度,返回时换大块保证据完整。
解法:索引粒度与生成粒度解耦
做法上没有任何黑魔法。入库时把文档切成两层:父块是章节或大段级别(比如 800~2000 字),子块是段落或固定窗口级别(比如 150~400 字)。只有子块进向量索引,每个子块携带一个指向父块的 ID;父块本体存在普通的键值存储里,不参与向量检索。查询时按常规流程在子块上做向量检索(可叠加 BM25 混合与重排序),拿到命中的 top-k 子块后,不把子块本身送进 prompt,而是按 ID 回溯取出各自的父块,去重合并后交给模型。
flowchart LR
A["文档"] --> B["父块:章节级大块"]
B --> C["子块:段落级小块"]
C --> D["向量索引(只存子块)"]
B --> E["父块存储(KV)"]
Q["用户查询"] --> D
D --> F["命中子块 top-k"]
F --> G["按 parent_id 回溯"]
E --> G
G --> H["去重合并,送入生成"]
这个结构有个很容易被忽略的性质:父块只是「取」出来的,不是「检索」出来的,所以父块可以任意大——两千字也行,整篇文档也行,都不影响向量检索的精度。粒度解耦之后,两个诉求各归各位。
三种常见形态
工程上这一思想落地为三种形态,成本和收益略有差别。
标准父子块:子块索引、父块返回,就是上文描述的默认形态。适合文档有天然层级(章节、条款)的场景,比如手册、规章、教材。父块大小建议控制在模型上下文预算的单块上限以内——父块不是越大越好,超过千字后无关内容开始拖累生成质量,后面《上下文组装》一篇会给出预算分配的具体算法。
句子窗口(sentence-window retrieval):把切块做到极致——索引粒度是单句,返回时取该句前后各 N 句拼成一个窗口。它相当于「父块」退化为一个围绕命中句的滑动窗口,比固定父块更贴合命中的局部语境。LlamaIndex 把它做成了一等公民的检索模式,适合句子间相对独立、没有强章节结构的语料,比如 FAQ 库、论文段落。
层级父链:子块命中后先取中块(章节摘要级),再由中块挂大块(全文级),按需逐层向上取。它服务于「命中点多、证据分散在全文各处」的查询,代价是上下文暴涨,通常配合后面的压缩步骤使用。RAPTOR(《层级索引》一篇)把「层级」做进了索引本身,思路同源但更激进。
动手实现一个最小父子检索器
核心数据结构只有两张表:子块表(带 parent_id,进向量索引)和父块表(KV 存储)。下面是去掉工程外衣的最小实现,检索部分用暴力余弦代替向量库,逻辑完全等价:
import numpy as np
def norm(v):
return v / np.linalg.norm(v)
class ParentChildRetriever:
def __init__(self, embed_fn, dim=768):
self.embed = embed_fn
self.child_vecs = [] # 子块向量矩阵
self.child_ids = [] # 子块 id
self.child_parent = {} # 子块 id -> 父块 id
self.parents = {} # 父块 id -> 父块文本
def add_document(self, doc_id, parent_text, child_texts):
self.parents[doc_id] = parent_text
for i, c in enumerate(child_texts):
cid = f"{doc_id}#{i}"
self.child_ids.append(cid)
self.child_parent[cid] = doc_id
self.child_vecs.append(norm(np.array(self.embed(c))))
def search(self, query, top_k=5, max_parents=3):
q = norm(np.array(self.embed(query)))
mat = np.array(self.child_vecs)
scores = mat @ q # 余弦相似度
order = np.argsort(-scores)[:top_k]
picked, seen = [], set()
for idx in order: # 按 parent 去重,避免一个父块占满 top-k
pid = self.child_parent[self.child_ids[idx]]
if pid in seen:
continue
seen.add(pid)
picked.append({"parent_id": pid, "text": self.parents[pid],
"score": float(scores[idx])})
if len(picked) >= max_parents:
break
return picked
注意 max_parents 这个参数:命中的 top-k 个子块经常指向同一个父块,不去重的话,prompt 里会出现同一大块复制多份的荒唐场面。按父块去重、父块内按最高子块分排序,是这套检索器的默认姿势。
与重排序的配合:流水线的正确排布
父子检索上线后最常见的质疑是「检索结果变厚了,噪声是不是也变多了」。答案取决于流水线排布:让 reranker 作用于子块层,父子化放在精排之后,噪声问题就不存在。完整的排布是四步——第一步混合召回(《混合检索与重排序》)在子块上取 top-50;第二步 reranker 对这 50 个子块做语义精排,选出真正相关的 top-5 子块;第三步按 parent_id 取父块并去重,父块内部按「它包含的最高分子块分数」排序;第四步组装进上下文(《上下文组装》),控制父块总预算。这个排布里 reranker 看到的是短小聚焦的子块——它最擅长的输入形态——而父子化只负责「把选中的小块换成完整的证据容器」,两各环节都在自己最舒适的位置上。
反过来,如果父子化放在 rerank 之前(先取父块再精排),reranker 面对的是大段材料,判别精度下降,且父块间的重叠会拖慢打分。排布错了,父子检索的收益会被重排序的劣化吃掉一半以上。
参数落地:三个数字怎么定
落地时需要拍三个数字。子块大小:沿用切块策略篇的结论,150~400 字、10%~15% 重叠,语义聚焦优先。父块大小:以下两个约束取小——「不超过上下文预算除以 max_parents」(保证证据条数)与「不超过模型注意力的高效区间」(经验上 1000~2000 字为宜,再大无关内容开始拖累生成)。max_parents:3 是常见起点,覆盖「答案分散在两三处」的主流情形;聚合型查询(《RAPTOR》一篇的题型)可能需要 5 以上,配合预算动态放宽。三个参数都不是拍完就完——它们正是《评测集构建》尺子要量化的对象:每组配置跑一遍评测集的 recall@k 与答案要点覆盖率,曲线自己会说话。
三个高频故障与排查
上线后看三个典型故障。故障一:同一父块重复出现——子块去重逻辑缺失或 parent_id 映射错乱,prompt 里同一大段出现两三份,模型答案开始车轱辘话。排查点在检索器的去重步骤与入库时的 id 生成。故障二:答案总是「答一半」——父块切得太大,预算装不下几个父块,证据条数不足;解法是缩小父块或启用层级父链按需上探。故障三:父子映射悬空——文档更新时父块重建了、子块索引没跟上(或反之),检索命中一个指向不存在父块的子块。这正是《增量更新与模型迁移》强调「以源文档为原子、全删全建」的原因:映射的完整性靠变更规程保证,不靠运行时补救。
工程取舍:什么时候值得用
父子检索不是免费午餐,有三笔账要算。索引成本翻倍:切块、嵌入、存储都要处理两层,父子映射还要随文档更新维护(文档改动时父块重切,子块级联重建,《增量更新与模型迁移》一篇讲的级联失效在这里同样适用)。上下文预算更紧:返回的是大块,同样的 token 预算装下的「证据条数」变少,如果一个查询的答案分散在多个父块里,塞三块就超预算,这时反而不如小粒度直出。对生成端要求更高:大块意味着更多无关内容混入,重排序(先在子块层面精选,再取父块)几乎是标配——实践里最常见的组合是「子块召回 → reranker 精排子块 → 取父块」,把两篇前置文章(BM25/向量混合检索与 Reranker)直接串在这条流水线上。
反过来,判断要不要用的标准很朴素:答案常常被切块切断——用户抱怨「答了一半」「话没说完」「结论和依据对不上」,就是父子上场的信号。如果现有小块已经能完整覆盖答案(FAQ 类语料),上父子纯属增加复杂度。
一个决策流程:五分钟判断要不要上
收尾给一个可以直接走的决策清单,四问定方案。第一问:答案会被切块切断吗?抽 20 条真实坏例看——答案明显「半截话」的比例超过三成,父子检索的收益就有保障;反之先做别的优化。第二问:语料有层级结构吗?手册、规章、教材类走标准父子块;FAQ、段落独立的语料走句子窗口。第三问:预算装得下吗?按「max_parents × 父块字数」估算证据区预算,装不下就先缩父块。第四问:reranker 有了吗?没有先上 reranker(性价比更高),父子检索是它之后的第二层增益。四问全是「是」,父子检索大概率就是当前性价比最高的检索改良——它的全部投入是入库时多存一层映射,没有新模型、没有新服务,是本系列所有手段里「改动最小、感知最强」的那一档。
小结
父子检索的本质是一次关注点分离:匹配精度交给小块,证据完整交给大块,中间用 parent_id 连接。它不引入任何新模型,只是把切块从「一个粒度服务所有环节」改造成「每个环节用最合适的粒度」,是性价比最高的检索质量改良之一。配合句子窗口应对无结构语料、配合层级父链应对证据分散,再让 reranker 在中间把关,这条「小块找、大块答」的流水线能覆盖绝大多数企业知识库场景。
参考资料
- LangChain: Parent Document Retriever —— 标准父子检索的参考实现与参数说明
- LlamaIndex: Sentence Window Retrieval —— 句子窗口形态的官方示例
- 本系列前篇:《RAG 切块策略实战》《混合检索与重排序》《一篇读懂 Reranker》——父子检索的上下游配套
读者留言
COMMENTS 暂无还没有留言,来说第一句?