链载Ai

标题: 高级 RAG 检索策略之递归检索 [打印本页]

作者: 链载Ai    时间: 2 小时前
标题: 高级 RAG 检索策略之递归检索



随着 LLM(大语言模型)技术的发展,RAG(Retrieval-Augmented Generation)技术在问答、对话等任务中的应用越来越广泛。RAG 技术的一个重要组成部分是文档检索器,它负责从大量的文档中检索出与问题相关的文档,以供 LLM 生成答案。RAG 检索器的效果直接影响到 LLM 生成答案的效果,因此如何设计高效的 RAG 检索器是一个重要的研究课题。目前,有多种 RAG 的检索策略,本文将介绍一种高级的 RAG 检索策略——递归检索,它通过递归的方式检索相关文档,可以提高检索的效果。

递归检索介绍

递归检索相较于普通 RAG 检索,可以解决后者因文档切片过大而导致检索信息不准确的问题,下面是递归检索的流程图:



在LlamaIndex[1]的实现中,递归检索主要有两种方式:块引用的递归检索和元数据引用的递归检索。

普通 RAG 检索

在介绍递归检索之前,我们先来看下使用 LlamaIndex 进行普通 RAG 检索的代码示例:

from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core import VectorStoreIndex

question = "奥创是由哪两位复仇者联盟成员创造的?"

documents = SimpleDirectoryReader("./data").load_data()
node_parser = SentenceSplitter(chunk_size=1024)
base_nodes = node_parser.get_nodes_from_documents(documents)
print(f"base_nodes len: {len(base_nodes)}")
for idx, node in enumerate(base_nodes):
node.id_ = f"node-{idx}"
base_index = VectorStoreIndex(nodes=base_nodes)
base_retriever = base_index.as_retriever(similarity_top_k=2)
retrievals = base_retriever.retrieve(question)
for n in retrievals:
print(
f"Node ID: {n.node_id}\nSimilarity: {n.score}\nText: {n.text[:100]}...\n"
)
response = base_retriever.query(question)
print(f"response: {response}")
print(f"len: {len(response.source_nodes)}")

我们来看下程序运行的结果:

base_nodes len: 15
Node ID: node-0
Similarity: 0.8425314373498192
Text: 神盾局解散后,由托尼·斯塔克、史蒂芬·罗杰斯、雷神、娜塔莎·罗曼诺夫、布鲁斯·班纳以及克林特·巴顿组成的复仇者联盟负责全力搜查九头蛇的下落,这次透过“盟友”提供的情报而进攻位于东欧的国家“索科维亚”的...

Node ID: node-1
Similarity: 0.8135015554872678
Text: 奥创来到克劳位于南非的武器船厂获取所有振金,并砍断克劳的左手。复仇者们到达后跟他们正面交锋,但大多数人被旺达用幻象术迷惑,看到各自心中最深层的“阴影”;唯独托尔看见在家乡阿萨神域发生的不明景象。旺达同...

response: 奥创是由托尼·斯塔克和布鲁斯·班纳这两位复仇者联盟成员创造的。
nodes len: 2

可以看到通过文档解析器解析后的原始节点有 15 个,检索到的节点有 2 个,这两个节点都是原始节点。

块引用的递归检索

块引用的递归检索是在普通 RAG 检索的基础上,将每个原始文档节点拆分成更小的文档节点,这些节点跟原始节点是父子关系,当检索到子节点时,会递归检索到其父节点,然后再将父节点为检索结果提交给 LLM。

下面我们通过代码示例来理解块引用的递归检索,首先我们创建几个 chunk_size 更小的文档解析器:

sub_chunk_sizes = [128, 256, 512]
sub_node_parsers = [
SentenceSplitter(chunk_size=c, chunk_overlap=20) for c in sub_chunk_sizes
]

再通过文档解析器将原始节点解析成子节点:

from llama_index.core.schema import IndexNode

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)

original_node = IndexNode.from_text_node(base_node, base_node.node_id)
all_nodes.append(original_node)
print(f"all_nodes len: {len(all_nodes)}")

# 显示结果
all_nodes len: 331



然后我们再创建检索索引,将所有节点传入,先对问题进行一次普通检索,观察普通检索的结果:

vector_index_chunk = VectorStoreIndex(all_nodes)
vector_retriever_chunk = vector_index_chunk.as_retriever(similarity_top_k=2)
nodes = vector_retriever_chunk .retrieve(question)
for node in nodes:
print(
f"Node ID: {node.node_id}\nSimilarity: {node.score}\nText: {node.text[:100]}...\n"
)

# 显示结果
Node ID: 0e3409e5-6c84-4bbf-886a-40e8553eb463
Similarity: 0.8476561735049716
Text: 神盾局解散后,由托尼·斯塔克、史蒂芬·罗杰斯、雷神、娜塔莎·罗曼诺夫、布鲁斯·班纳以及克林特·巴顿组成的复仇者联盟负责全力搜查九头蛇的下落,这次透过“盟友”提供的情报而进攻位于东欧的国家“索科维亚”的...

Node ID: 0ed2ca24-f262-40fe-855b-0eb84c1a1567
Similarity: 0.8435371049710689
Text: 奥创来到克劳位于南非的武器船厂获取所有振金,并砍断克劳的左手。复仇者们到达后跟他们正面交锋,但大多数人被旺达用幻象术迷惑,看到各自心中最深层的“阴影”;唯独托尔看见在家乡阿萨神域发生的不明景象。旺达同...

我们再来看看使用递归检索的检索结果:

from llama_index.core.retrievers import RecursiveRetriever

all_nodes_dict = {n.node_id: n for n in all_nodes}
retriever_chunk = RecursiveRetriever(
"vector",
retriever_dict={"vector": vector_retriever_chunk},
node_dict=all_nodes_dict,
verbose=True,
)
nodes = retriever_chunk.retrieve(question)
for node in nodes:
print(
f"Node ID: {node.node_id}\nSimilarity: {node.score}\nText: {node.text[:1000]}...\n"
)

# 显示结果
Retrieving with query id None: 奥创是由哪两位复仇者联盟成员创造的?
Retrieved node with id, entering: node-0
Retrieving with query id node-0: 奥创是由哪两位复仇者联盟成员创造的?
Node ID: node-0
Similarity: 0.8476561735049716
Text: 神盾局解散后,由托尼·斯塔克、史蒂芬·罗杰斯、雷神、娜塔莎·罗曼诺夫、布鲁斯·班纳以及克林特·巴顿组成的复仇者联盟负责全力搜查九头蛇的下落,这次透过“盟友”提供的情报而进攻位于东欧的国家“索科维亚”的...

最后使用 LLM 对问题生成答案:

from llama_index.core.query_engine import RetrieverQueryEngine

llm = OpenAI(model="gpt-3.5-turbo", temperature=0.1)
query_engine_chunk = RetrieverQueryEngine.from_args(retriever_chunk, llm=llm)
response = query_engine_chunk.query(question)
print(f"response: {str(response)}")
print(f"nodes len: {len(response.source_nodes)}")

# 显示结果
response: 奥创是由托尼·斯塔克和布鲁斯·班纳这两位复仇者联盟成员创造的。
nodes len: 1

可以看到递归检索生成的答案跟普通 RAG 检索生成的答案是一样的。

元数据引用的递归检索

基于元数据引用的递归检索和块引用的递归检索类似,只是在解析原始节点时,不是将原始节点进行拆分,而是根据原始节点来生成元数据子节点,然后再将元数据子节点和原始节点一起传入检索索引。

下面我们通过代码示例来理解元数据引用的递归检索,首先我们创建几个元数据的提取器:

from llama_index.core.extractors import (
SummaryExtractor,
QuestionsAnsweredExtractor,
)

extractors = [
SummaryExtractor(summaries=["self"], show_progress=True),
QuestionsAnsweredExtractor(questions=5, show_progress=True),
]

然后我们通过元数据提取器将原始节点解析成元数据子节点:

node_to_metadata = {}
for extractor in extractors:
metadata_dicts = extractor.extract(base_nodes)
for node, metadata in zip(base_nodes, metadata_dicts):
if node.node_id not in node_to_metadata:
node_to_metadata[node.node_id] = metadata
else:
node_to_metadata[node.node_id].update(metadata)

代码执行后 node_to_metadata 的数据结构如下所示:

{
"node-0": {
"section_summary": "...",
"questions_this_excerpt_can_answer": "1. ...?\n2. ...?\n3. ...?\n4. ...?\n5. ...?"
},
"node-1": {
"section_summary": "...",
"questions_this_excerpt_can_answer": "1. ...?\n2. ...?\n3. ...?\n4. ...?\n5. ...?"
},
......
}

我们可以将 node_to_metadata 的数据保存到文件中,方便后续使用,这样就不用每次都调用 LLM 来生成元数据了。

import json

def save_metadata_dicts(path, data):
with open(path, "w") as fp:
json.dump(data, fp)


def load_metadata_dicts(path):
with open(path, "r") as fp:
data = json.load(fp)
return data

save_metadata_dicts("output/avengers_metadata_dicts.json", node_to_metadata)
node_to_metadata = load_metadata_dicts("output/avengers_metadata_dicts.json")

我们再将原始节点和元数据子节点组合成一个新的节点列表:

import copy

all_nodes = copy.deepcopy(base_nodes)
for node_id, metadata in node_to_metadata.items():
for val in metadata.values():
all_nodes.append(IndexNode(text=val, index_id=node_id))
print(f"all_nodes len: {len(all_nodes)}")

# 显示结果
all_nodes len: 45

我们可以看下新节点列表中node-0原始节点和其子节点的内容:

node0_nodes = list(
filter(
lambda x: x.id_ == "node-0"
or (hasattr(x, "index_id") and x.index_id == "node-0"),
all_nodes,
)
)
print(f"node0_nodes len: {len(node0_nodes)}")
for node in node0_nodes:
index_id_str = node.index_id if hasattr(node, 'index_id') else 'N/A'
print(
f"Node ID: {node.node_id}\nIndex ID: {index_id_str}\nText: {node.text[:100]}...\n"
)

# 显示结果
node0_nodes len: 3
Node ID: node-0
Index ID: N/A
Text: 神盾局解散后,由托尼·斯塔克、史蒂芬·罗杰斯、雷神、娜塔莎·罗曼诺夫、布鲁斯·班纳以及克林特·巴顿组成的复仇者联盟负责全力搜查九头蛇的下落,这次透过“盟友”提供的情报而进攻位于东欧的国家“索科维亚”的...

Node ID: 45d41128-a8e6-4cdc-8ef3-7a71f01ddd96
Index ID: node-0
Text: The key topics of the section include the creation of Ultron by Tony Stark and Bruce Banner, the int...

Node ID: a06f3bb9-8a57-455f-b0c6-c9602b107158
Index ID: node-0
Text: 1. What are the names of the Avengers who raid the Hydra facility in Sokovia at the beginning of the...

然后我们再创建检索索引,将所有节点传入,先对问题进行一次普通检索,观察普通检索的结果:

vector_index_metadata = VectorStoreIndex(all_nodes)
vector_retriever_metadata = vector_index_metadata.as_retriever(similarity_top_k=2)

enginer = vector_index_metadata.as_query_engine(similarity_top_k=2)
nodes = enginer.retrieve(question)
for node in nodes:
print(
f"Node ID: {node.node_id}\nSimilarity: {node.score}\nText: {node.text[:100]}...\n"
)

# 显示结果
Node ID: d2cc032a-b258-4715-b335-ebd1cf80494d
Similarity: 0.857976008616706
Text: The key topics of the section include the creation of Ultron by Tony Stark and Bruce Banner, the int...

Node ID: node-0
Similarity: 0.8425314373498192
Text: 神盾局解散后,由托尼·斯塔克、史蒂芬·罗杰斯、雷神、娜塔莎·罗曼诺夫、布鲁斯·班纳以及克林特·巴顿组成的复仇者联盟负责全力搜查九头蛇的下落,这次透过“盟友”提供的情报而进攻位于东欧的国家“索科维亚”的...

上面是普通检索的结果,我们再来看使用递归检索的检索结果:

all_nodes_dict = {n.node_id: n for n in all_nodes}
retriever_metadata = RecursiveRetriever(
"vector",
retriever_dict={"vector": vector_retriever_metadata},
node_dict=all_nodes_dict,
verbose=False,
)
nodes = retriever_metadata.retrieve(question)
for node in nodes:
print(
f"Node ID: {node.node_id}\nSimilarity: {node.score}\nText: {node.text[:100]}...\n\n"
)

# 显示结果
Node ID: node-0
Similarity: 0.857976008616706
Text: 神盾局解散后,由托尼·斯塔克、史蒂芬·罗杰斯、雷神、娜塔莎·罗曼诺夫、布鲁斯·班纳以及克林特·巴顿组成的复仇者联盟负责全力搜查九头蛇的下落,这次透过“盟友”提供的情报而进攻位于东欧的国家“索科维亚”的...

最后使用 LLM 对问题生成答案:

query_engine_metadata = RetrieverQueryEngine.from_args(retriever_metadata, llm=llm)
response = query_engine_metadata.query(question)
print(f"response: {str(response)}")
print(f"nodes len: {len(response.source_nodes)}")

# 显示结果
response: 奥创是由托尼·斯塔克和布鲁斯·班纳这两位复仇者联盟成员创造的。
nodes len: 1

可以看到递归检索生成的答案跟普通 RAG 检索生成的答案是一样的。

检索效果对比

我们接下来使用Trulens[3]来评估普通 RAG 检索、块引用的递归检索和元数据引用的递归检索的效果。

tru.reset_database()
rag_evaluate(base_engine, "base_evaluation")
rag_evaluate(engine, "recursive_retriever_chunk_evaluation")
rag_evaluate(engine, "recursive_retriever_metadata_evaluation")
Tru().run_dashboard()

rag_evaluate的具体代码可以看我之前的文章,主要是使用 Trulens 的groundednessqa_relevanceqs_relevance对 RAG 检索结果进行评估。执行代码后,我们可以在浏览器中看到 Trulens 的评估结果:



在评估结果中,我们可以看到两种递归检索都比普通 RAG 检索效果要好,元数据引用的递归检索比块引用的递归检索效果更好一些,但评估结果并不是绝对的,具体的评估效果还要根据实际情况来评估。

总结

递归检索是一种高级的 RAG 检索策略,开始通过原始文档节点扩展出更多粒度更小的文档节点,这样在检索过程中可以更加准确地检索到相关的文档,然后再通过递归检索找出与之相匹配的原始文档节点。递归检索可以提高 RAG 检索的效果,但是也会增加检索的时间和计算资源,因此在实际应用中需要根据实际情况来选择合适的检索策略。

关注我,一起学习各种人工智能和 AIGC 新技术,欢迎交流,如果你有什么想问想说的,欢迎在评论区留言。

参考:

[1]

 LlamaIndex: https://www.llamaindex.ai/

[2]

 复仇者联盟: https://en.wikipedia.org/wiki/Avenger

[3]

 Trulens: https://www.trulens.org/







欢迎光临 链载Ai (https://www.lianzai.com/) Powered by Discuz! X3.5