通过父子分段与分层索引提升RAG检索效果
为何小片段虽能赢得匹配却得不到正确答案,以及父子分段、去重扩展和分层索引如何为RAG检索恢复上下文。
来源: Parent–Child Chunking and Hierarchical Indexing in RAG: A Practical Guide to Better Retrieval
当RAG系统给出的答案几乎正确,但缺少了某个关键细节时,问题通常出在文本的分割方式上,而非嵌入向量本身。有两种技术可直接解决这一问题:父子分块法,即搜索小片段但读取大片段;层次索引法,通过遵循文档自身的结构来缩小搜索范围。这两种方法都旨在同时为语言模型提供精确度与上下文信息。
为何文本分割是难点
基本的处理流程很简单:将文档分割成若干片段,对它们进行嵌入处理并存储起来,针对查询提取排名靠前的k个片段,然后将其传递给模型。一旦出现故障,问题通常会追溯到第一步。过大的片段会降低相关性并使嵌入结果变得模糊,而过小的片段则会割裂掉问题所依赖的上下文句子。固定大小、递归分割、语义分割以及句子窗口分割,都是为了解决单个文本单元内的这种矛盾而提出的方法。
还有一个不同的思路值得尝试:不必执着于寻找一个完美的片段大小,而是同时使用两种大小,每种大小针对不同的任务进行优化。在采用这种改进方案之前,先弄清楚单一大小究竟在哪些方面存在缺陷会很有帮助。
一个典型的失败案例
设想一段来自技术文档的两句话。第一句指出缓存能使系统运行速度大幅提升。第二句则警告说,不当的缓存失效处理会导致旧数据残留。
一个小型分割器可以将这段文字分成两块。A块是赞扬性能的那句话,B块则是关于因失效处理不当而产生的旧数据的那句话。现在的问题是:缓存存在什么风险?下面的脚本将这个问题以及这两块文本嵌入其中,然后通过余弦相似度对它们进行比较。
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("all-MiniLM-L6-v2")
def cosine_similarity(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
query = "What is a risk of caching?"
chunk_a = "Caching improves system performance significantly."
chunk_b = "However, improper cache invalidation can lead to stale data issues."
query_embedding = model.encode(query)
score_a = cosine_similarity(query_embedding, model.encode(chunk_a))
score_b = cosine_similarity(query_embedding, model.encode(chunk_b))
print(f"Chunk A similarity: {score_a:.4f} - '{chunk_a}'")
print(f"Chunk B similarity: {score_b:.4f} - '{chunk_b}'")
运行它后会输出每个片段与问题的相似度。
Chunk A similarity: 0.5891 — 'Caching improves system performance significantly.'
Chunk B similarity: 0.5103 — 'However, improper cache invalidation can lead to stale data issues.'
尽管是B部分回答了问题,但A部分的得分更高,约为0.59,而B部分约为0.51。两者都提到了缓存,因此看起来相关,但嵌入模型并没有特殊理由将“风险”这个词与那段从未使用过该词的过时数据相关联。如果检索器只选择最匹配的结果,很可能会将错误的部分内容传递给模型。
这说明语义相似性并不能保证内容的完整性。一个片段虽然可以被正确地判定为与主题相关,但仍可能缺少问题所需的信息,而且片段越短,这些信息就越有可能出现在相邻的句子中。
父子片段划分
其思路是同时存储同一文本的两种不同粒度版本,各自承担不同的功能。
子片段规模小且精确。它们通常包含100到400个标记,旨在实现高召回率的嵌入搜索。较小的规模能够生成更清晰的向量,因为较少的无关内容不会稀释每个向量所代表的意义。不过,如果缩减到仅几个词,反而会产生反效果,因为这样的片段可能缺乏实现可靠匹配所需的独立含义。
父块规模较大且具有上下文关联性。它们通常包含800到3000个标记甚至更多,涵盖整个章节或逻辑单元,其存在目的是用于推理而非匹配。在缓存示例中,包含完整段落的父块涵盖了该观点的两部分内容,因此无论哪个子块被匹配到,模型依然能看到完整的情境。其上限由模型的上下文窗口决定,因为多个大型父块组合在一个提示语中会挤占提示语中其他必需的内容。
这种关系可以用一句话概括:子块是用于搜索的对象,而父块则是用于阅读的内容。子块必须足够精确才能在相似度比较中胜出,而其父块则确保模型能够获得该子块答案所依赖的上下文。
实际应用中的表现
想象一个名为“数据库性能优化”的章节。父子分割器通过两步来完成工作:首先确定父级边界,即涵盖索引、查询优化、缓存以及相关权衡的整个章节内容;然后将该文本进一步拆分成多个子部分,例如:
- 一个关于通过缩短扫描时间来提升查询速度的索引相关内容,
- 一个关于通过缓存减少重复计算但可能存在数据过时的内容,
- 一个关于重写SQL查询以实现优化的内容。
该方案能够正常运行的关键在于每个子部分都记得自己的来源。最简单的方法是将父级文本的完整内容存储在每个子部分的元数据中,就像这个索引函数所做的那样。
def build_parent_child_index(document_section, parent_text, child_splitter, embedding_model):
child_chunks = child_splitter(parent_text)
records = []
for child_text in child_chunks:
records.append({
"text": child_text,
"embedding": embedding_model.encode(child_text),
"parent_text": parent_text # full parent stored directly on every child
})
return records
parent_text = """Indexing improves query speed by reducing scan time across large tables.
Caching reduces repeated computation but may introduce staleness if not invalidated properly.
Query optimization involves rewriting SQL queries to use more efficient execution plans."""
child_texts = [
"Indexing improves query speed by reducing scan time across large tables.",
"Caching reduces repeated computation but may introduce staleness if not invalidated properly.",
"Query optimization involves rewriting SQL queries to use more efficient execution plans.",
]
records = [
{"text": t, "parent_text": parent_text} for t in child_texts
]
for r in records:
print(f"Child: {r['text'][:50]}...")
print(f" → linked to parent ({len(r['parent_text'])} chars)\n")
将父节点复制到每个子节点会占用更多存储空间,但在查询时无需额外查找,因为父节点已存在于子节点的记录中。另一种方法是仅存储parent_id,在需要时再获取父节点,这样在大规模应用中可以节省空间,但会增加一次查找步骤。无论采用哪种方式,每个子节点都能像其他数据块一样独立被查询,同时还能直接追溯到其完整的上下文。
先获取少量数据,再扩展大量数据
这改变了常规的处理流程。标准的RAG流程是获取数据、发送数据、给出答案。而父子节点版本的流程则是先获取一个子节点,再扩展到其父节点,发送经过丰富处理的上下文,最后给出答案。以下是在缓存示例基础上实现的该流程。
def parent_child_retrieve(query, child_records, embedding_model, top_k=3):
query_embedding = embedding_model.encode(query)
scored = [
(np.dot(query_embedding, r["embedding"]) /
(np.linalg.norm(query_embedding) * np.linalg.norm(r["embedding"])), r)
for r in child_records
]
scored.sort(key=lambda x: x[0], reverse=True)
top_children = scored[:top_k]
# Expand each matched child to its parent - but deduplicate first
seen_parents = set()
expanded_context = []
for score, record in top_children:
parent = record["parent_text"]
if parent not in seen_parents:
expanded_context.append(parent)
seen_parents.add(parent)
return expanded_context
for r in records:
r["embedding"] = model.encode(r["text"])
context = parent_child_retrieve("What is a risk of caching?", records, model, top_k=2)
print("Context sent to the LLM:\n")
for c in context:
print(c)
需要注意seen_parents的检查机制。当最顶层的两个子项属于同一个父项时——这种情况很常见,因为同一部分的相邻片段往往得分相近——如果分别处理它们,就会重复发送相同的父项文本,从而浪费上下文信息。而在父项层面进行去重处理,就能确保无论有多少个子项匹配,每段上下文都只出现一次。
对于缓存内容而言,模型现在会同时接收到匹配成功的句子及其周围的说明,因此无论哪个子项排名靠前,这两部分内容都会一起被传递。
其作用原理
- 结合上下文的精确度。子项负责定位正确位置,而父项则提供单个子项无法承载的信息。
- 更优的推理能力。模型难以处理相互割裂的片段,而父项有助于恢复叙述的连贯性。
分层索引
如果父子关系仅为两级,那么分层索引则更为复杂。不必只考虑一个父节点及其子节点,而应构建反映文档实际组织结构的体系。一个示例层次结构可能如下所示。
Document: "Cloud Architecture Guide"
Section: Networking
Subsection: Load Balancing
Chunk: Round-robin method
Chunk: Least connections method
Subsection: CDN usage
Section: Security
Subsection: IAM policies
Subsection: Encryption
注意这种层级结构是从何处产生的。有些系统是按类别进行分类的,比如部门、主题或独立于任何文档而设定的元数据标签。而这里的层级结构则源自文档自身的标题和章节,即作者对其的编排方式。这两种方式都能在搜索前缩小搜索范围,但前者是按主题组织整个知识库,后者则是通过目录来组织单篇文档。
大多数文档并非单纯的纯文本。标题和子标题已经体现了各种概念之间的关联关系。层级索引保留了这种结构,使系统能够先找到相应章节,再找到子章节,最后定位到确切的内容片段,就如同人们在仔细阅读之前先浏览目录一样。
实际检索过程
假设查询内容为“最小连接数负载均衡是如何工作的?”系统会首先找到“网络”板块,再进一步筛选至“负载均衡”子板块,最终获取说明最小连接数方法会选择当前活跃连接数最少的服务器的相关内容。
每一个筛选步骤都需要具体的机制来实现,因为这些过程不会自动发生。可行的方案包括使用经过训练的分类器来预测查询属于哪个板块,或者调用大型语言模型读取查询内容并从已知列表中选出最可能的板块,还有基于板块标题和关键词的规则匹配。无论选择哪种方式,都需在每个层级上执行一次——先对板块进行筛选,再对子板块进行筛选,之后才开始在具体内容块层面进行搜索。
额外的层级结构真的有必要吗?对于大多数文档来说,两级结构就已经足够。只有当内容篇幅很长、结构极为复杂,且某个部分包含众多独立的子主题,以至于一次性搜索所有内容会降低相关性的时候,才需要完整的多层级结构。简短的FAQ页面或单主题文章通常只需两级结构,而拥有数十个子部分的百页级架构指南则可能需要更多层级。与任何索引决策一样,应在增加复杂性之前先进行评估。
快速调整技巧
首先选择处于范围中间的子文本长度,约为200个标记,然后在更改任何设置之前用少量真实问题测试召回率。记录每次查询中被扩展的父文本,以便找出总是被包含且可能过于宽泛的部分。限制发送给模型的父文本数量,因为三四个较大的部分就已经足以填满有限的上下文窗口,同时保留原始的子文本以便引用,这样用户就能清楚看到是哪句话匹配上了。
两者对比
- 范围:父子结构针对同一文本的两种不同粒度进行操作,而层次索引则对整个大纲进行建模。
- 主要优势:父子结构能够保留匹配项周围的上下文,而层次结构则会缩小搜索的起始范围。
这两种结构可以很好地结合使用,因为层次结构的底部可以以父子结构块的形式呈现。
可直接使用的处理流程
- 数据导入。解析文档并识别标题。加载器必须保留标题信息,即明确哪些文本属于哪个部分,而不能将所有内容合并为单一数据流,因为后续步骤依赖于这些信息。
- 分块。先从完整章节中构建父块,然后再将每个父块拆分为子块,同时首先确定父块的边界。
parent_id以便查询。需避免的错误
- 仅使用子项。虽然精度更高,但会丢失推理所需的上下文,正如缓存示例中所出现的那样,B部分永远无法获得完成答案的句子。
- 仅使用父项。虽然能保留上下文,但检索精度会下降,因为将多主题内容合并为一个向量会像过大的数据块一样模糊其特征。
parent_text或parent_id这样的显式父级链接,日后就无法进行扩展。元数据必须在数据块创建时就被捕获,因为一旦被丢弃就无法恢复。最能发挥作用的场景
在文档篇幅长、结构化且相互关联的场合,这些模式尤为有效:企业知识库、内部文档、支持机器人、法律与合规档案、工程维基以及研究助手系统。在这类资料中,没有任何一种固定的数据块大小能够同时满足精确性和上下文需求。
RAG正从单纯的“搜索后再生成”模式转向一种先梳理有序知识、再对所获信息进行推理的模型。父子分块技术提升了局部理解能力,因此无论匹配结果出现在何处,其周围的解释内容也会一同呈现。分层索引则优化了全局导航,使搜索能从合适的范围开始,而非遍历所有内容。扁平分块则类似于从书中撕下页面,然后让模型自行推测情节。这两种技术虽然让书籍保持装订状态,但仍能让你查找到精确的行文。实际上,这意味着几乎正确的答案会更少,而这正是最令用户沮丧的缺陷;同时,当有人询问为何某段文字会被模型选中时,这种检索层的行为也更容易解释。