Child-Parent RecursiveRetriever与LlamaIndex中的句子窗口检索
RAG(检索增强生成)系统从给定的知识库中检索相关信息,从而使其能够生成具有事实性、语境相关性和领域特定性的信息。然而,RAG在有效检索相关信息并生成高质量响应方面面临诸多挑战。在这一系列的博文/视频中,我将介绍一些高级的RAG技术,旨在优化RAG工作流程并解决天真的RAG系统中的挑战。
第一项技术被称为小到大检索。在基本的RAG流程中,我们嵌入一个大的文本块进行检索,并且这个完全相同的文本块被用于合成。但是,有时候嵌入/检索大的文本块可能感觉不太理想。一个大的文本块中可能有很多填充文本,它们隐藏了语义表征,导致检索更差。如果我们能够基于更小、更有针对性的块进行嵌入/检索,但仍然为LLM提供足够的上下文来合成响应,这是否会有优势呢?具体而言,解耦用于检索的文本块与用于合成的文本块可以带来优势。使用较小的文本块可以增加检索的准确性,而较大的文本块则提供更多的上下文信息。小到大检索背后的概念是在检索过程中使用较小的文本块,然后将所检索到的文本所属的较大文本块提供给大型语言模型。
这里有两种主要的技术:
- 较小的子文本块指向较大的父文本块:首先在检索过程中获取较小的文本块,然后引用父ID,并返回较大的文本块。
- 句子窗口检索:在检索过程中获取一个句子,并返回该句子周围的文本窗口。
在本博文中,我们将深入介绍这两种方法在LlamaIndex中的实现。为什么不在LangChain中进行?因为已经有很多关于LangChain高级RAG的资源。我不想重复努力。另外,我同时使用LangChain和LlamaIndex。了解更多工具并灵活运用它们是最好的选择。
您可以在这个笔记本中找到所有的代码。
基本RAG回顾
我们从一个基本的RAG实现开始,它有4个简单的步骤:
第1步:加载文档
我们使用PDFReader加载PDF文件,并将文档的每个页面合并成一个Document对象。
loader = PDFReader()docs0 = loader.load_data(file=Path("llama2.pdf"))doc_text = "\n\n".join([d.get_content() for d in docs0])docs = [Document(text=doc_text)]
第2步:将文档解析为文本块(节点)
然后,我们将文档分割为文本块,在LlamaIndex中称为“节点”。在这里,我们将块大小定义为1024。默认的节点ID是随机的文本字符串,我们可以按照特定的格式设定节点ID。
node_parser = SimpleNodeParser.from_defaults(chunk_size=1024)base_nodes = node_parser.get_nodes_from_documents(docs)for idx, node in enumerate(base_nodes):node.id_ = f"node-{idx}"
第3步:选择嵌入模型和LLM
我们需要定义两个模型:
- 嵌入模型用于为每个文本块创建向量嵌入。这里我们调用了来自Hugging Face的FlagEmbedding模型。
- LLM:用户查询和相关文本块被馈送到LLM中,以便它能够生成具有相关上下文的答案。
我们可以在ServiceContext中将这两个模型捆绑在一起,并在索引和查询步骤中后续使用它们。
embed_model = resolve_embed_model(“local:BAAI/bge-small-en”)llm = OpenAI(model="gpt-3.5-turbo")service_context = ServiceContext.from_defaults(llm=llm, embed_model=embed_model)
第4步:创建索引、检索器和查询引擎
索引、检索器和查询引擎是询问关于您的数据或文档的问题的三个基本组件:
- 索引是一种数据结构,允许我们从外部文档中快速检索与用户查询相关的信息。向量存储索引将文本块/节点采取并为每个节点的文本创建向量嵌入,以便由LLM查询。
base_index = VectorStoreIndex(base_nodes, service_context=service_context)
- 检索器用于根据用户查询提取和检索相关信息。
base_retriever = base_index.as_retriever(similarity_top_k=2)
- 查询引擎建立在索引和检索器之上,提供一个通用接口,用于询问关于您的数据的问题。
query_engine_base = RetrieverQueryEngine.from_args( base_retriever, service_context=service_context)response = query_engine_base.query( "你能告诉我关于安全微调的关键概念吗?")print(str(response))
高级方法1:更小的子块引用更大的父块
在上一节中,我们对检索和合成都使用了固定的块大小1024。本节中,我们将探讨如何在检索中使用更小的子块,并在合成中引用更大的父块。第一步是创建更小的子块:
步骤1:创建更小的子块
对于每个块大小为1024的文本块,我们创建了更小的文本块:
- 8个大小为128的文本块
- 4个大小为256的文本块
- 2个大小为512的文本块
我们将大小为1024的原始文本块追加到文本块列表中。
sub_chunk_sizes = [128, 256, 512]sub_node_parsers = [ SimpleNodeParser.from_defaults(chunk_size=c) for c in sub_chunk_sizes]all_nodes = []for base_node in base_nodes: for n in sub_node_parsers: sub_nodes = n.get_nodes_from_documents([base_node]) sub_inodes = [ IndexNode.from_text_node(sn, base_node.node_id) for sn in sub_nodes ] all_nodes.extend(sub_inodes) # also add original node to node original_node = IndexNode.from_text_node(base_node, base_node.node_id) all_nodes.append(original_node)all_nodes_dict = {n.node_id: n for n in all_nodes}
当我们查看所有文本块 `all_nodes_dict` 时,我们可以看到许多较小的块与每个原始文本块关联,例如 `node-0`。实际上,所有这些较小的块都是与元数据中指向较大块的 index_id 关联的。
步骤2:创建索引、检索器和查询引擎
- 索引:创建所有文本块的向量嵌入。
vector_index_chunk = VectorStoreIndex( all_nodes, service_context=service_context)
- 检索器:关键在于使用RecursiveRetriever来遍历节点关系,并基于 “references” 获取节点。这个检索器将递归地探索节点到其他检索器/查询引擎的链接。对于任何检索到的节点,如果其中任何节点都是 IndexNodes,它将探索链接的检索器/查询引擎并对其进行查询。
vector_retriever_chunk = vector_index_chunk.as_retriever(similarity_top_k=2)retriever_chunk = RecursiveRetriever( "vector", retriever_dict={"vector": vector_retriever_chunk}, node_dict=all_nodes_dict, verbose=True,)
当我们提问并检索最相关的文本块时,实际上会检索到以指向父块的节点ID指向的文本块,从而检索到父块。
- 现在按照之前的步骤,我们可以创建一个作为通用接口的查询引擎,用于询问关于我们的数据的问题。
query_engine_chunk = RetrieverQueryEngine.from_args( retriever_chunk, service_context=service_context)response = query_engine_chunk.query( "你能告诉我关于安全微调的关键概念吗?")print(str(response))
高级方法2:句子窗口检索
为了实现更细粒度的检索,我们可以将文档解析为每个块只包含一个句子。
在这种情况下,单个句子类似于我们在方法1中提到的“子”块的概念。句子“窗口”(原始句子两侧的5个句子)类似于“父”块的概念。换句话说,我们在检索过程中使用单个句子,并将检索的句子与句子窗口一起传递给LLM。
步骤1:创建句子窗口节点解析器
# create the sentence window node parser w/ default settingsnode_parser = SentenceWindowNodeParser.from_defaults( window_size=3, window_metadata_key="window", original_text_metadata_key="original_text",)sentence_nodes = node_parser.get_nodes_from_documents(docs)sentence_index = VectorStoreIndex(sentence_nodes, service_context=service_context)
步骤2:创建查询引擎
在创建查询引擎时,我们可以使用MetadataReplacementPostProcessor将句子替换为句子窗口,以便将句子窗口发送给LLM。
query_engine = sentence_index.as_query_engine( similarity_top_k=2, # the target key defaults to `window` to match the node_parser's default node_postprocessors=[ MetadataReplacementPostProcessor(target_metadata_key="window") ],)window_response = query_engine.query( "你能告诉我关于安全微调的关键概念吗?")print(window_response)
句子窗口检索能够回答问题“你能告诉我关于安全微调的关键概念吗?”:
在这里,您可以看到实际检索到的句子以及提供更多上下文和细节的句子窗口。
结论
在本博文中,我们探讨了如何使用从小到大的检索来改进RAG,重点是使用Child-Parent RecursiveRetriever和带有LlamaIndex的句子窗口检索。在未来的博文中,我们将更深入地探讨其他技巧和提示。敬请期待更多有关这个激动人心的高级RAG技术的内容!
参考资料:
- https://docs.llamaindex.ai/en/latest/examples/node_postprocessor/MetadataReplacementDemo.html
- https://docs.llamaindex.ai/en/stable/examples/retrievers/recursive_retriever_nodes.html

作者:Sophia Yang,发布日期:2023年11月4日