RAG(Retrieval Augmented Generation,检索增强生成)系统从给定的知识库中检索相关信息,从而使其能够生成事实信息、上下文相关信息和特定领域的信息。然而,在有效检索相关信息和生成高质量响应方面,RAG面临着许多挑战。在这一系列的博客文章/视频中,我将介绍先进的RAG技术,旨在优化RAG工作流程,并解决原始RAG系统中的挑战。
第一种技术被称为从小到大的检索。在基本的RAG管道中,我们嵌入一个大的文本块进行检索,而这个完全相同的文本块用于合成。但有时嵌入/检索大的文本块可能会感觉不太理想。在一个大的文本块中有很多填充文本,导致文本块的语义可能不明显,导致检索更差。如果我们可以基于更小、更有针对性的块进行嵌入/检索,但仍有足够的上下文供LLM合成响应,该怎么办?具体而言,将用于检索的文本块与用于合成的文本块解耦可能是有利的。使用较小的文本块可以提高检索的准确性,而较大的文本块则提供更多的上下文信息。小到大检索背后的概念是在检索过程中使用较小的文本块,然后将检索到的文本所属的较大文本块提供给大语言模型。
主要有两种技术:
-
较小的子块引用较大的父块:在检索过程中首先获取较小的块,然后引用父ID,并返回较大的块;
-
句子窗口检索:在检索过程中获取一个句子,并返回句子周围的文本窗口。
在这篇博客文章中,我们将深入研究这两种方法在LlamaIndex中的实现。为什么我不在LangChain中做这件事?因为LangChain的高级RAG已经有很多资源了。我宁愿不重复这种努力。此外,我同时使用LangChain和LlamaIndex。最好了解更多的工具并灵活使用它们。
一、基本RAG回顾
准备工作:
安装相关包
! pip install -U llama_hub llama_index braintrust autoevals pypdf pillow transformers torch torchvision
设置环境变量
import os
os.environ["OPENAI_API_KEY"] = "TYPE YOUR API KEY HERE"
下载数据集
!wget --user-agent "Mozilla" "https://arxiv.org/pdf/2307.09288.pdf" -O "llama2.pdf"
导入所需要的包
from pathlib import Path
from llama_hub.file.pdf.base import PDFReader
from llama_index.response.notebook_utils import display_source_node
from llama_index.retrievers import RecursiveRetriever
from llama_index.query_engine import RetrieverQueryEngine
from llama_index import VectorStoreIndex, ServiceContext
from llama_index.llms import OpenAI
import json
让我们从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中被称为“节点”,我们将chuck大小定义为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:选择embedding模型和LLM
我们需要定义两个模型:
- embedding模型用于为每个文本块创建矢量嵌入。在这里,我们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)
- Retriever用于获取和检索给定用户查询的相关信息。
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(
"Can you tell me about the key concepts for safety finetuning"
)
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指向较大块的索引id。
步骤2:创建索引、检索器和查询引擎
- 索引:创建所有文本块的矢量嵌入。
vector_index_chunk = VectorStoreIndex(
all_nodes, service_context=service_context
)
- Retriever:这里的关键是使用RecursiveRetriever遍历节点关系并基于“引用”获取节点。这个检索器将递归地探索从节点到其他检索器/查询引擎的链接。对于任何检索到的节点,如果其中任何节点是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(
"Can you tell me about the key concepts for safety finetuning"
)
print(str(response))
三、高级方法2:语句窗口检索
为了实现更细粒度的检索,我们可以将文档解析为每个块的一个句子,而不是使用更小的子块。
在这种情况下,单句将类似于我们在方法1中提到的“子”块概念。句子“窗口”(原句子两侧各5句)将类似于“父”组块概念。换句话说,我们在检索过程中使用单个句子,并将检索到的带有句子窗口的句子传递给LLM。
步骤1:创建句子窗口节点解析器
# create the sentence window node parser w/ default settings
node_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(
"Can you tell me about the key concepts for safety finetuning"
)
print(window_response)
语句窗口检索能够回答“你能告诉我安全微调的关键概念吗”的问题:
在这里,您可以看到检索到的实际句子和句子的窗口,它提供了更多的上下文和细节。
结论
在这篇博客中,我们探讨了如何使用从小到大的检索来改进RAG,重点是使用LlamaIndex的父子递归检索器和句子窗口检索。在未来的博客文章中,我们将深入探讨其他技巧和窍门。请继续关注这场激动人心的先进RAG技术之旅!
参考文献:
[1] https://towardsdatascience.com/advanced-rag-01-small-to-big-retrieval-172181b396d4
[2] https://colab.research.google.com/github/sophiamyang/demos/blob/main/advanced_rag_small_to_big.ipynb