做RAG工程化的朋友,十有八九在chunking上翻过车。检索召回率挺漂亮,答案却答得稀里糊涂;或者反过来,上下文给足了,关键证据偏偏没被召回。父子索引(Parent-Child Index)就是我在这个困境里试出来的一套可落地方案:子块负责定位,父块负责喂料,两者通过元数据绑定。这篇文章不讲纯理论,只讲落地,包括数据结构怎么设计、查询链路怎么写、实测效果怎么样、调参时有哪些坑。适合正在做知识库问答、私有文档检索,或者被固定长度切块折腾到头秃的开发者参考。
1. 先说结论:父子索引解决的是“切碎”与“切开”之间的两难
1.1 小块检索更“准”,但答案往往缺胳膊少腿
做向量检索的人都清楚,embedding模型最后会把一整段文本压成一个向量。文本越短,向量表达的语义就越集中,query里的关键词越容易在相似度计算中占据主导地位,召回命中率自然更高。我做过一组小实验:同样一份操作手册,切成120到200字的小块,检索Top20的命中率能到70%以上;但把这些小块拼起来丢给大模型,回答效果却很差——因为答案需要的上下文往往被切在上一块里了。
典型场景是“某某参数为什么设置成这个值”。小块的向量里只包含“参数值”,解释性的因果句子落在相邻块,模型拿到了一个孤立数字,只能照着字面硬编,幻觉率一下就上去了。
1.2 大块上下文完整,但向量语义被稀释
那直接切大块行不行?也不行。1024字符甚至更大的块,能把“因果链”保留在窗口内,生成质量确实会提升,但embedding向量会被块里的多个主题拉向“平均语义”。举个例子,一个块里既有“安全警告”,又有“扭矩调整步骤”,又有“备件清单”,query问“扭矩调整步骤”时,这个块的整体向量跟query的相似度反而被另外两块内容稀释了。结果就是:相关块排在几十名开外,模型拿到的还是那些定位不精准的东西。
这里有本质矛盾:匹配和阅读对文本粒度的需求是相反的。匹配需要“小而专”,阅读需要“大而全”。固定chunk_size只能二选一,这就是RAG工程里最常见的一个两难。
1.3 父子索引的思路:让检索和阅读各司其职
父子索引的思路很简单,把这两个需求拆到两层:
- 子块(Child):切得小,几百字以内,专门用来做向量检索匹配。
- 父块(Parent):按照标题、章节、段落切,保留完整语义单元,专门作为喂给大模型的上下文。
- 映射关系:每个子块记录自己属于哪个父块,检索时先在子块上命中,再通过映射找回父块文本。
一行字概括:子块建索引,父块做上下文,子块命中后溯源到父块。这个思路不是炫技,它只是把“做一个好的索引文档”这个老工程方法论搬到了RAG场景里。它解决的是匹配精度和上下文完整性之间的取舍问题,而不是替代embedding或者rerank。
2. 父子索引的数据结构设计:父块、子块与映射关系
2.1 先定切割口径,再谈存储
设计父子索引的第一步不是写代码,而是定清楚两类块各自怎么切。
父块的切割准则要偏向“语义完整性”。我一般优先按文档结构切:一级标题下面是一节,这一节里如果段落太长,再按二级标题或语义段落切。目标是每个父块讲一个相对独立的事情,下面这张表格列一下我的经验默认值,实际按语料调整。
| 块类型 | 默认大小范围 | 切割依据 | 目的 |
|---|---|---|---|
| 父块 | 600-1200字符 | 标题、段落、列表边界 | 构成完整上下文 |
| 子块 | 100-250字符 | 按句子或固定窗口加重叠切 | 提升检索精度 |
| 子块间重叠 | 20-50字符 | 窗口式切分时自然产生 | 避免语义断点 |
子块的切割不需要太在意“块内是不是一个完整意思”,它只负责做匹配,稍微碎一点没关系。但要注意子块别把句子从中间拦腰切断。强烈建议子块按句子边界切,宁可在长度上稍有浮动,也不要把“PID控制器”切成“PID”和“控制器”两半。
2.2 元数据是父子索引的命脉
父块和子块之间靠什么联系?靠元数据里的一个parent_id字段,而不是靠再存一份父块向量。
很多第一次做父子索引的人会问:父块要不要也向量化,然后两个索引一起检索?我的建议是:不要。原因有两个。
第一,父块向量存储成本高,检索时还会把真正的子块匹配结果挤掉。第二,子块命中后直接按parent_id取父块文本,这一步是O(1)的字典查询,完全没必要再走一次向量计算。向量库里的每条记录,核心字段就这几项:
text:子块原文vector:子块向量doc_id:所属文档IDparent_id:所属父块IDsection_path:章节路径,形如“第三章 > 3.2 调整步骤”child_order:子块在父块内的序号parent_text:父块全文
有人把parent_text单独放到另一个表或另一个collection里,查询时二次拉取。这样也行,分库的好处是向量库只存高频访问的子块,父块文本放对象存储或者文档库,成本更低。小规模项目直接把parent_text塞进元数据字段最省事,少一次网络往返。
2.3 一套可以直接改着用的构建伪代码
下面这段伪代码,思路比具体语法重要,按照这个结构去实现基本不会错:
class ParentChildIndex: def build(self, document): # 1. 按标题/段落切父块,保留章节路径 parents = split_by_structure(document) for p_idx, parent in enumerate(parents): parent_id = f"{document.id}#{p_idx}" # 2. 在父块内部按句子/窗口切子块 children = split_child_windows(parent.text) for order, child in enumerate(children): child["vector"] = embed(child.text) child["metadata"] = { "doc_id": document.id, "parent_id": parent_id, "section_path": parent.section_path, "child_order": order, "parent_text": parent.text, } self.vector_db.insert(child["vector"], child["metadata"])一句话点评这段代码的关键所在:parent_text在写入时直接随每个子块复制一份,表面上是冗余存储,但它让检索阶段少了一次“根据parent_id查父块”的额外IO。当然如果父块特别大、单条记录超限,那就得走外置存储,这个取舍后面调优部分细说。
3. 工程落地的实施路径:从Parser到Retriever
3.1 解析与结构化:先保住文档骨架
真实文档不是干净的纯文本,PDF、Word、HTML导出的内容里夹杂着表格、页眉、页脚、目录。如果直接用正则或者固定长度去切,最大的问题是子块无法知道自己属于哪个章节。
我的做法是,先统一转成Markdown或者带标题层级的HTML,再用解析器抽标题树。这个“结构化前置”是父子索引能否生效的分水岭。原因很简单:父块的section_path是从标题树拼出来的,子块继承了父块的结构路径。没有这个路径,检索出来的上下文就是一堆“无家可归”的文本碎片,模型连这段话在文档的哪个位置都感知不到。
切块顺序上,我的固定流程是:
- 按一级标题切分,得到若干个中块。
- 中块超过父块上限时,再按二级标题或段落切。
- 段落过长且内部没有标题,再按语义段或固定长度兜底。
- 切完父块后,再按句子边界切子块。
这套流程最终产出的是嵌套关系清晰的父块序列,而不是一堆“等长吐司片”。
3.2 索引写入:向量库的元数据处理
写入阶段最容易忽略的是批次大小和失败重试。embedding接口也好,自建模型也好,单条文本越长、批次越大,内存占用的波动就越明显。小语料看不出问题,几十万条子块的情况下,一个批次塞512条可能直接把内存打满。
我的建议是:批次大小先设为64,压测后按耗时和内存曲线调整,同时为重试留好接口。子块写入向量库时,一定要把父块去重逻辑想在前头:同一个父块下的所有子块,写入时都应该带上同一个parent_id。这一步做对了,后面查询去重才简单。
有些场景还需要考虑删改。文档更新后,最稳妥的方式是按doc_id先删旧索引再写入新索引,千万不要做“找到变了的那几个子块再增量更新”这种复杂度极高的优化,收益很低。
3.3 查询链路实现:召回、聚合、拼装
查询阶段看起来只是“查向量库拿TopK”,但父子索引要求你在召回之后多做两件事:按parent_id聚合去重,按原文档顺序拼装上下文。
完整链路我一般这样写:
def retrieve(query, top_k=20, max_context_len=800): q_vec = embed(query) hits = vector_db.search(q_vec, top_k=top_k) # 1. 按 parent_id 聚合子块 parent_hits = {} for hit in hits: pid = hit.metadata["parent_id"] if pid not in parent_hits or hit.score > parent_hits[pid].max_score: parent_hits[pid] = { "parent_text": hit.metadata["parent_text"], "max_score": max(parent_hits.get(pid, {}).get("max_score", 0), hit.score), "hit_count": parent_hits.get(pid, {}).get("hit_count", 0) + 1, "section_path": hit.metadata["section_path"], "doc_id": hit.metadata["doc_id"], } # 2. 按最大相似度排序,同分时命中子块数多者优先 ranked = sorted( parent_hits.values(), key=lambda x: (x["max_score"], x["hit_count"]), reverse=True ) # 3. 按章节先后顺序排序拼装,避免返回的上下文语序错乱 context_blocks = [] for r in ranked: context_blocks.append(f"[来源: {r['section_path']}]\n{r['parent_text']}") if sum(len(b) for b in context_blocks) > max_context_len: break return "\n\n".join(context_blocks)这里有个细节值得展开:为什么按parent_id去重之后,排序用的是子块的最大相似度,而不是多个子块得分的总和?因为一个父块如果被切成了5个子块,query跟其中4个子块的相似度都一般,总和很容易超过另一个“精准命中一次”的父块。我们要的是“最相关的那一小段落在哪”,不是“哪个父块被撞次数最多”。实际测试里,按最大分排序比按总和排序更稳,命中子块数只作为同分时的tie-breaker。
3.4 混合检索怎么融合
父子索引常见的问题场景是专有名词:设备型号、报错码、人名、产品名。向量检索对于这类“低频但精确”的token不敏感,关键词检索反而一打一个准。所以实际落地时,我一般把BM25/关键词检索加进来,做一次混合召回。
融合方式不用搞太复杂,最简单的加权和就够用:
score = 0.6 * vector_sim + 0.4 * keyword_score
关键词这一路同样可以从子块上召回,再按parent_id聚合。两路召回的parent_id集合做并集,分数取加权后的排序。注意,关键词得分和向量相似度不在一个量纲,最好先各自做归一化,否则权重形同虚设。
混合检索解决的是“精确定位”,父子索引解决的是“精确定位之后的上下文完整度”,两者互补,不冲突。
4. 实测:扁平切块与父子索引的效果对比
4.1 我的评测设计
我自己在某个内部产品手册项目上做过一轮对比实验。语料是30页操作手册,包含流程说明、参数表、异常处理三部分,问题集我人工标注了30道,其中20道单跳、10道多跳。
指标没有只看“检索命中率”,还看了两个更贴近业务的值:
- 答案正确率:人工判断回答是否符合文档原文。
- 引用可溯性:回答中的信息能不能回溯到父块的
section_path,防止模型一本正经地胡说。
4.2 三组方案的对比结果
| 方案 | 检索Top20命中率 | 答案正确率 | 平均输入上下文长度 |
|---|---|---|---|
| 固定256字符小块 | 0.73 | 0.41 | 约1200字符(多段拼接) |
| 固定1024字符大块 | 0.61 | 0.52 | 约1024字符 |
| 父子索引 | 0.75 | 0.77 | 约900字符 |
这组数据规模不大,但趋势和很多公开讨论一致:小块检索确实准,但答案正确率被上下文残缺拖垮;大块上下文完整了,但召回也掉了;父子索引把两个优势粘在了一起,答案正确率从0.52提升到0.77,而且喂给模型的上下文比固定1024字符还短一截——因为只回传命中的父块,而不是机械地传固定长的块。
多跳题目的差异更明显。固定小块的方案在多跳问题上几乎全军覆没,原因是第一跳命中的上下文里根本没有第二跳要用的信息。父子索引因为父块本身就是完整的语义单元,跨两段的信息往往能在一个父块里找齐,多跳题正确率从0.30左右拉到了0.70上下。
4.3 表格和代码块这种边界场景
文档里最让RAG头疼的是表格、代码块这类非连续文本。固定长度切块会把表格从中间切开,比如把三列参数拆进了三个不同的chunk,模型看到的永远是不完整的一行。代码块被拦腰切更是灾难,函数声明在前一个块,函数体在后一个块,聚合出来的上下文毫无价值。
父子索引在这种场景下有天然优势:父块按表格/代码块整体为一个语义块切,子块再在里面做小粒度切分。比如一个表格父块可能只有300字符,没有达到默认父块长度上限,那就直接整体作为一个父块,对应的子块也才一到两个。检索命中的时候,回传的是完整表格。这个细节在挑“答案正确率”指标的时候非常值钱。
4.4 父子索引的适用边界
做了一轮测试后,也顺手试了试它的“不适用场景”。如果语料本身是短文本,比如每条本来就一两百字的FAQ,再切父子块就完全是脱裤子放屁,直接把原文当父块、当子块即可。还有一类是对话数据,语义边界往往在“整段对话”,而不是“单轮消息”,父子索引帮助有限。
从成本角度看,父子索引的构建复杂度比扁平切块高一档,因为每个子块都要额外记录parent_id,还需要对父块做一次结构化切分。当语料不足几百篇、问题又都是简单单跳时,这个额外成本回不了本。
5. 踩坑记录与调优建议
5.1 父子块比例别拍脑袋
很多教程喜欢直接给“父块1000、子块200”这种数字,实际上这个比例应该由你的语料密度决定。我的经验是先统计文档结构:段落中位字数如果是400,父块拆成600-800是合理的;如果语料长短句混杂,子块按句子边界切,长度浮动到300也没关系。
一个实用的调参思路是:先固定父块,让子块去适配。因为父块决定了喂给模型的上下文质量,子块只影响召回精度。若发现“检索不到”,优先缩小子块;若发现“检索到了但答案是残的”,优先扩大父块。两级参数尽可能独立调,不要同步改。
5.2 父块去重与排序:别被命中次数带偏
前面在伪代码里提过,排序用每个父块的“子块最大相似度”。这里再补充一个真实踩过的坑:有个父块被切出来的子块数量特别多,单次query命中了其中5个子块,分数都不高,但“命中次数累加”把它推到了第一名。结果就是模型拿到的上下文是错的,还因为section_path带对了章节名,乍一看很有道理,排查起来特别费劲。
改成“最大相似度优先,命中块数tie-break”之后,这个问题直接消失。此外,建议给子块的命中分数设一个下限阈值,比如0.35以下直接丢弃,这一招对控制上下文噪音非常有效。阈值别设太高,不同embedding模型的分数分布差异很大,0.3到0.5之间按实际召回率调。
5.3 token预算和超长父块的处理
父块一旦变大,比如上千字符,拼装多个父块很容易把上下文窗口塞爆。我的处理策略是“优先保Hit区域”,而不是从父块开头全量截断。
具体做法是:拼装Context时,先找到命中的子块在父块内的偏移位置,然后从命中位置向两侧扩展,扩展到预设上下文上限就停。这样做的好处是,如果父块里只有一段相关,模型依然能拿到那一段周围的完整因果链条;如果父块整体都很长,就不用把无关的开头部分浪费进来。
再极限一点的场景,比如父块实在太大,可以做一次“先压缩再回填”:把父块用摘要模型压缩成两三百字的摘要,放进上下文,同时保留parent_id供溯源。这个方案适合超大文档,但要注意压缩本身可能引入信息丢失,不能当默认路径。
5.4 子块太碎导致专有名词/关键信息被切断
这个坑我提过两次,但值得单独说:子块切割时如果按固定长度硬切,很常见的问题是把“PID控制器”切成“PID”和“控制器”,把“HTTPS证书”切成“HTTPS”和“证书”。query是“PID控制器怎么调参”时,embedding匹配会被拆碎的子块带偏。
解法有两种。第一种,子块按句子边界切,宁可长度浮动,不让关键词被拦腰切断。第二种,父块本身比较短(比如一个短段落只有200字),就直接把父块同时当作子块,不额外切。很多框架里叫“短父块透传”,本质是避免无意义的二次切分。
5.5 上线前记得给引用留“案底”
最后一条经验,不算是技术坑,但排查问题的时候能救命的:所有回传给模型的上下文,建议在日志里记录doc_id、parent_id、section_path三个字段。线上用户说“答案不对”时,你会非常快地定位是“该召回的内容没召回”,还是“召回了但模型没用对”。没有这个日志,你只能靠猜,猜来猜去最容易出的问题就是把调优时间浪费在错误的环节上。
我在实际项目里的做法是:每次回答都在结果旁返回一个sources字段,同时打印命中父块的section_path。这让“调试RAG”这件事从玄学变成了有迹可循。
最后再分享一个我自己的体会:父子索引不是银弹,它最大的价值是让“检索准”和“回答全”这两件事从互斥变成了可兼得。如果你的项目痛点正好是“检索命中了但答案质量差”,不妨先从小范围语料做起,用标题层级切父块、按句子切子块,把parent_id元数据埋好,再对照上面几个坑逐步调。这套方法论不需要额外引入多复杂的组件,但工程收益通常立竿见影。