在上一篇博客:父文档检索器 中我们介绍了langchain中的两种父文档检索方式即:“检索完整文档”和“检索较大的文档块”。今天我们要介绍llamaIndex中与langchain的父文档检索有点相似的检索方法即“从小到大的检索”。

一、LlamaIndex 简介

LlamaIndex是一个用于连接大语言模型(LLMs)和外部数据源的数据框架,它可以让LLMs访问和利用私有或领域特定的数据。LlamaIndex提供了以下功能:

  • 数据连接:支持从本地文件、Notion、Google文档、Slack、Discord等多种数据源读取数据。
  • 数据索引:支持构建不同类型的索引结构,如列表索引、向量索引、树形索引、关键词表索引等,以便快速检索和过滤数据。
  • 查询接口:支持使用自定义的输入提示(prompt)与LLMs进行交互,以获取知识增强的响应。

LlamaIndex的目标是简化数据处理和LLMs集成的过程,让开发者和用户能够轻松构建基于数据的LLMs应用,如文档问答、数据增强的聊天机器人、知识代理等。LlamaIndex是一个开源项目,您可以在GitHub上查看其源码和文档。

二、环境配置

我们需要安装以下python包:

pip install -U llama_hub llama_index braintrust autoevals pypdf pillow transformers torch torchvision

接下来我们需要做一些初始化的工作,比如导入openai,gemini等大模型的api_key:

import os
from dotenv import load_dotenv, find_dotenv

_ = load_dotenv(find_dotenv()) 

三,LlamaIndex 的基本RAG

3.1 加载数据

今天我们同样使用上一篇博客中使用的相同数据即从百度百科的网页中抓取两篇关于ChatGPT和恐龙的文章,这里我们使用的是LlamaIndex 的数据爬虫工具:TrafilaturaWebReader

from llama_index.readers.web import TrafilaturaWebReader

docs = TrafilaturaWebReader().load_data(
         ["https://baike.baidu.com/item/ChatGPT/62446358",
          "https://baike.baidu.com/item/恐龙/139019"]
)

想了解更多LlamaIndex的网页爬虫工具的朋友可以查看LIamaIndex的官方文档。接下来我们查看一下docs中的文档数量:

len(docs)

 这里我们看到docs中已经有2篇文章了。下面我们查看其中一篇文档中的部分内容:

print(docs[0].text[:1000])

接下来我们要实现文档的切割工作,这也是文档检索的必要步骤之一,和Langchain相似的是LlamaIndex也需要创建文档的切割器:

from llama_index.node_parser import SimpleNodeParser
from llama_index.schema import IndexNode

#创建文档切割器
node_parser = SimpleNodeParser.from_defaults(chunk_size=1024)
node_parser

这里需要说明一下的是在LIamaIndex中我们把文档块称为“节点(node)”,文档切割器称为“解析器(parser)”,和Langchain一样的是在创建文档切割器时我们也需要设置文档块大小(chunk_size),另外还有一些可选参数如重叠字符串长度(chunk_overlap)等,这里我们看到文档切割器node_parser 它有着自己的文档切割规则,比如:separator=' ', paragraph_separator='\n\n\n',secondary_chunking_regex='[^,.;。?!]+[,.;。?!]?'。这些和Langchain的文档切割规则也有所差异。下面我们要用文档切割器node_parser来切割文档:

base_nodes = node_parser.get_nodes_from_documents(docs)

len(base_nodes)

 这里我们看到原来的docs中的2个文档被切割成了40个文档(nodes),下面我们来看一下第一个node的内容:

base_nodes[10]

 这里我们需要特别说明的是在被切割出来的每一个节点中都包含了:本节点自身Id(id_)、父节点Id(SOURCE)、上级节点Id(PREVIOUS)、下级节点Id(NEXT),这里的父节点Id指的是原始文档Id即docs里面的文档Id,而上/下级节点指的是当前节点的上一个和下一个节点。从这里我们可以看出LIamaIndex的文档块信息中包含了较为完整的上下文索引信息。

3.2 设置Embedding 和 LLM

这里我们仍然使用的是BAAI的中文Embedding模型bge-small-zh-v1.5,关于为什么要使用BAAI的Embedding模型请查看我之前写的Embedding模型的选择这篇博客,为了比较不同大模型之间的检索效果,我同时使用了OpenAI的ChatGPT和谷歌的Gemini模型这两种大模型,在下面的代码中可以切换不同的模型:

from llama_index.embeddings import resolve_embed_model
from llama_index import VectorStoreIndex, ServiceContext
from llama_index.llms import OpenAI
# from llama_index.llms import Gemini


#创建BAAI的embedding
embed_model = resolve_embed_model("local:BAAI/bge-small-zh-v1.5")

#创建OpenAI的llm
llm = OpenAI(model="gpt-3.5-turbo")

#创建谷歌gemini的llm
#llm = Gemini()

#创建service_context 
service_context = ServiceContext.from_defaults(
    llm=llm, embed_model=embed_model
)

3.3 创建 Index, retriever, query engine

这里我们创建了embedding模型embed_model ,llm和service_context 。要实现基本检索功能,我们还需要创建Index和 retriever组件,其中Index承担向量数据库的角色,而retriever是一个检索组件它负责从Index中根据文档的相似度来检索相关文档:

#创建index
base_index = VectorStoreIndex(base_nodes, service_context=service_context)
#创建检索器
base_retriever = base_index.as_retriever(similarity_top_k=2)

#检索相关文档
retrievals = base_retriever.retrieve(
    "恐龙是冷血动物吗?"
)

接下来我们可以查看和问题:"恐龙是冷血动物吗?"相关的文档:

from llama_index.response.notebook_utils import display_source_node

for n in retrievals:
    display_source_node(n, source_length=1500)

这里我们看到由于我们在创建检索器的时候设置了similarity_top_k=2,即让检索器每次都返回2个与用户问题相似度最高的文档,在上面的返回结果中我们同时还看到检索器除了返回相关node的内容(Text)以外,还返回了Node Id,相似度值Similarity。接下来我们要请出大模型ChatGPT和Gemini来根据检索器返回的相关文档来回答用户的问题,下面我们看看ChatGPT的回答:

#openai的回答
response = query_engine_base.query(
    "恐龙是冷血动物吗?"
)
print(str(response))

下面我们看看Gemini模型是怎么回答的:

#gemini的回答
response = query_engine_base.query(
    "恐龙是冷血动物吗?"
)
print(str(response))

这里我们省略了切换模型的步骤,直接给出了两个大模型的回答,从回答的内容上看ChatGPT的回答比较全面,它除了给出结论同时也给恐龙不是冷血动物的理由,而gemini只给出了一个简单的结论,并没有给出理由。这里我们还要强调的是,两个大模型是基于前面的检索器搜索到的那两篇相关文档以及用户的问题后给出的答案。很明显OpenAI模型的回答更加全面。

四、从小到大的检索

4.1 创建更小的文档块

从小到大的检索是指我们在切割文档时可以同时设置多个不同的chunk_size的颗粒度,比如我们可以同时设置chunk_size为128,256,512即按这三个不同的颗粒度对同时对所有文档都切割一遍,下面我们来创建一个文档切割器集:

sub_chunk_sizes = [128, 256, 512]
sub_node_parsers = [
    SimpleNodeParser.from_defaults(chunk_size=c,chunk_overlap=0) for c in sub_chunk_sizes
]

sub_node_parsers

这里我们创建了三个文档切割器集,其中包含了三个不同尺寸(chunk_size)的文档切割器,我们会用这个切割器集轮流对之前已经被切割过的文档集base_node(chunk_size=1024)再次进行切割,最后汇总从一个大的文档集:

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)

我们需要说明之前的文档集base_node是按chunk_size=1024的尺寸来进行切割,并切割成了40个文档,这里我们是按chunk_size=[128,256,512]的颗粒度对这40个文档轮流再切割一遍,最后再加上它们的父节点文档。那么基本上原先的一个1024大小的文档块可以被切分成:

  • 8 个大小为 128 的文本块
  • 4 个大小为 256 的文本块
  • 2 个大小为 512 的文本块

接下来我们看一下切割好的总文档数:

len(all_nodes)

这里我们看到总文档数由原来的40个变成了701个文档。下面我们来看一下all_nodes中的前文档的内容:

all_nodes[:2]

这里我们看到all_nodes的每一篇文档都包含了本节点自身Id(id_)、父节点Id(SOURCE)、上级节点Id(PREVIOUS)、下级节点Id(NEXT)。接下来我们要创建一个节点Id和节点对应的字典all_nodes_dict,然后根据特定的节点id来查看其节点内容:

all_nodes_dict = {n.node_id: n for n in all_nodes}

#查看特定节点
all_nodes_dict['e252e03c-5c92-406e-b935-34e60e4bf9fa']

4.2 创建Index, retriever, 和 query engine 

接下来我们需要创建Index, retriever, 和 query engine,这里我们需要创建一个递归检索器RecursiveRetriever,它会根据节点之间的关系如:source,previous,next 去递归的搜索这些相关节点,这样可以更快的获取和用户问题相关的节点。

from llama_index.retrievers import RecursiveRetriever

vector_index_chunk = VectorStoreIndex(
    all_nodes, service_context=service_context
)

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,
)

下面我们通过检索器来检索与之前问题相关的文档:

nodes = retriever_chunk.retrieve(
   "恐龙是冷血动物吗?"
)
for node in nodes:
    display_source_node(node, source_length=2000)

 下面我们来看看ChatGPT和Gemini是如何依据相关文档来回答问题的吧,我们首先创建一个query_engine,然后用它来提出问题:

query_engine_chunk = RetrieverQueryEngine.from_args(
    retriever_chunk, service_context=service_context
)

 下面我们省去了大模型切换的代码,大家可以用前面定义llm时的代码来切换不同的模型,下面是ChatGPT对问题:恐龙是冷血动物吗?的回答:

#openai llm 的回答
response = query_engine_chunk.query(
    "恐龙是冷血动物吗?"
)
print(str(response))

下面是Gemini模型的回答:

#gemini的回答
response = query_engine_chunk.query(
    "恐龙是冷血动物吗?"
)
print(str(response))

 这里我们看到ChagGPT的回答更加完整全面即给出了结论,又给出了理由,而Gemini的回答仍然只给出结论却没有给出理由。大家怎么看?

五、总结

今天我们学习了LlamaIndex的基本RAG和从小到大的两种检索文档的方法,这可以和Langchain的父文档检索器方法互为补充,后面我们还会介绍LlamaIndex的其他文档检索方法以及评估文档检索效果的方法,希望今天的内容对大家学习RAG由所帮助。

六、参考资料

Web Page Reader - LlamaIndex 🦙 0.9.22 

Recursive Retriever + Node References - LlamaIndex 🦙 0.9.22

 Metadata Replacement + Node Sentence Window - LlamaIndex 🦙 0.9.22

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐