¿Cómo crear una aplicación resistente utilizando LlamaIndex?

LlamaIndex es un marco popular para crear aplicaciones LLM. Para crear una aplicación sólida, necesitamos saber cómo contar los tokens incrustados antes de crearlos, asegurarnos de que no haya duplicados en el almacén de vectores, obtener datos de origen para la respuesta generada y muchas otras cosas.

Este artículo revisará los pasos para crear una aplicación resistente utilizando LlamaIndex.

Objetivos de aprendizaje

  • Comprenda los componentes y funciones esenciales del marco LlamaIndex para crear aplicaciones LLM sólidas.
  • Aprenda a crear y ejecutar una canalización de ingesta eficiente para transformar, analizar y almacenar documentos.
  • Obtenga conocimientos sobre cómo inicializar, guardar y cargar documentos y almacenes de vectores para gestionar el almacenamiento de datos persistentes de forma eficaz.
  • Domine la creación de índices y el uso de mensajes personalizados para facilitar consultas eficientes e interacciones continuas con motores de chat.

Requisitos previos

A continuación se presentan algunos requisitos previos para crear una aplicación utilizando LlamaIndex.

Utilice el archivo .env para almacenar la clave OpenAI y cárguela desde el archivo

import os
from dotenv import load_dotenv

load_dotenv('/.env') # provide path of the .env file
OPENAI_API_KEY = os.environ['OPENAI_API_KEY']

Usaremos el ensayo de Paul Graham como documento de ejemplo. Se puede descargar desde aquí https://github.com/run-llama/llama_index/blob/main/docs/docs/examples/data/paul_graham/paul_graham_essay.txt

Cómo crear una aplicación utilizando LlamaIndex

Cargar los datos

El primer paso para crear una aplicación utilizando LlamaIndex es cargar los datos.

from llama_index.core import SimpleDirectoryReader
documents = SimpleDirectoryReader(input_files=["./data/paul_graham_essay.txt"], 
filename_as_id=True).load_data(show_progress=True)

# 'documents' is a list, which contains the files we have loaded

Veamos las claves del objeto del documento.

documents[0].to_dict().keys()

# output
"""
dict_keys(['id_', 'embedding', 'metadata', 'excluded_embed_metadata_keys', 
'excluded_llm_metadata_keys', 'relationships', 'text', 'start_char_idx', 
'end_char_idx', 'text_template', 'metadata_template', 'metadata_seperator', 
'class_name'])
"""

Podemos modificar los valores de esas claves como lo hacemos con un diccionario. Veamos un ejemplo con metadatos.

Si queremos agregar más información sobre el documento, podemos agregarla a los metadatos del documento de la siguiente manera. Estas etiquetas de metadatos se pueden utilizar para filtrar los documentos.

documents[0].metadata.update({'author': 'paul_graham'})

documents[0].metadata

# output
"""
{'file_path': 'data/paul_graham_essay.txt',
 'file_name': 'paul_graham_essay.txt',
 'file_type': 'text/plain',
 'file_size': 75042,
 'creation_date': '2024-04-16',
 'last_modified_date': '2024-04-15',
 'author': 'paul_graham'}
"""

Tubería de ingestión

Con la canalización de ingesta, podemos realizar todas las transformaciones de datos, como analizar el documento en nodos, extraer metadatos para los nodos, crear incrustaciones, almacenar los datos en el almacén de documentos y almacenar las incrustaciones y el texto de los nodos en el vector. almacenar.

Esto nos permite mantener todo lo necesario para que los datos estén disponibles para su indexación en un solo lugar.

Más importante aún, el uso del almacén de documentos y del almacén de vectores garantizará que no se creen incrustaciones duplicadas si guardamos y cargamos el almacén de documentos y los almacenes de vectores y ejecutamos el proceso de ingesta en los mismos documentos.

image

Conteo de fichas

El siguiente paso en la creación de una aplicación utilizando LlamaIndex es el recuento de tokens.

import the dependencies
import nest_asyncio

nest_asyncio.apply()

import tiktoken

from llama_index.core.callbacks import CallbackManager, TokenCountingHandler

from llama_index.core import MockEmbedding
from llama_index.core.llms import MockLLM

from llama_index.core.node_parser import SentenceSplitter,HierarchicalNodeParser

from llama_index.core.ingestion import IngestionPipeline

from llama_index.core.extractors import TitleExtractor, SummaryExtractor

Inicializar el contador de tokens

token_counter = TokenCountingHandler(
    tokenizer=tiktoken.encoding_for_model("gpt-3.5-turbo").encode,
    verbose=True
)

Ahora, podemos pasar a crear una canalización de ingesta utilizando MockEmbedding y MockLLM.

mock_pipeline = IngestionPipeline(
 transformations = [SentenceSplitter(chunk_size=512, chunk_overlap=64),
 TitleExtractor(llm=MockLLM(callback_manager=CallbackManager([token_counter]))),
 MockEmbedding(embed_dim=1536, callback_manager=CallbackManager([token_counter]))])

nodes = mock_pipeline.run(documents=documents, show_progress=True, num_workers=-1)

El código anterior aplica un divisor de oraciones a los documentos para crear nodos, luego utiliza incrustaciones simuladas y modelos llm para la extracción de metadatos y la creación de incrustaciones.

Luego, podemos verificar los recuentos de tokens.

# this returns the count of embedding tokens 
token_counter.total_embedding_token_count

# this returns the count of llm tokens 
token_counter.total_llm_token_count

# token counter is cumulative. When we want to set the token counts to zero, we can use this
token_counter.reset_counts()

Podemos probar diferentes analizadores de nodos y extractores de metadatos para determinar cuántos tokens se necesitarán.

Crear tiendas de documentos y vectores

El siguiente paso en la creación de una aplicación utilizando LlamaIndex es crear almacenes de documentos y vectores.

from llama_index.embeddings.openai import OpenAIEmbedding

from llama_index.core.storage.docstore import SimpleDocumentStore

from llama_index.vector_stores.chroma import ChromaVectorStore

import chromadb

Ahora podemos inicializar las tiendas de documentos y vectores.

doc_store = SimpleDocumentStore()

# mention the path, where vector store is saved
chroma_client = chromadb.PersistentClient(path="./chroma_db")

# we will create a collection if doesn't already exists
chroma_collection = chroma_client.get_or_create_collection("paul_essay")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
pipeline = IngestionPipeline(
    transformations = [SentenceSplitter(chunk_size=512, chunk_overlap=128),
    OpenAIEmbedding(model_name='text-embedding-3-small', 
              callback_manager=CallbackManager([token_counter]))],
    docstore=doc_store,
    vector_store=vector_store
)
nodes = pipeline.run(documents=documents, show_progress=True, num_workers=-1)

Una vez que ejecutamos la canalización, las incrustaciones se almacenan en el almacén de vectores para los nodos. También necesitamos guardar la tienda de documentos.

doc_store.persist('./document storage/doc_store.json')

# we can also check the embedding token count
token_counter.total_embedding_token_count

Ahora podemos reiniciar el kernel para cargar las tiendas guardadas.

Cargue las tiendas de documentos y vectores

Ahora, importemos los métodos necesarios, como se mencionó anteriormente.

# load the document store
doc_store = SimpleDocumentStore.from_persist_path('./document storage/doc_store.json')

# load the vector store
chroma_client = chromadb.PersistentClient(path="./chroma_db")
chroma_collection = chroma_client.get_or_create_collection("paul_essay")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)

Ahora, inicializas la canalización anterior nuevamente y la ejecutas. Sin embargo, no crea incrustaciones porque el sistema ya procesó y almacenó el documento. Entonces, agregamos cualquier documento nuevo a una carpeta, cargamos todos los documentos y ejecutamos la canalización, creando incrustaciones solo para el nuevo documento.

Podemos comprobarlo con lo siguiente

# hash of the document
documents[0].hash

# you can get the doc name from the doc_store
for i in doc_store.docs.keys():
    print(i)

# hash of the doc in the doc store
doc_store.docs['data/paul_graham_essay.txt'].hash

# When both of those hashes match, duplicate embeddings are not created. 

Busque en la tienda de vectores

Veamos qué se almacena en la tienda de vectores.

chroma_collection.get().keys()
# output
# dict_keys(['ids', 'embeddings', 'metadatas', 'documents', 'uris', 'data'])

chroma_collection.get()['metadatas'][0].keys()
# output
# dict_keys(['_node_content', '_node_type', 'creation_date', 'doc_id', 
  'document_id', 'file_name', 'file_path', 'file_size', 
  'file_type', 'last_modified_date', 'ref_doc_id'])

# this will return ids, metadatas, and documents of the nodes in the collection
chroma_collection.get() 

¿Cómo sabemos qué nodo corresponde a qué documento? Podemos mirar los metadatos node_content

ids = chroma_collection.get()['ids']

# this will print doc name for each node
for i in ids:
    data = json.loads(chroma_collection.get(i)['metadatas'][0]['_node_content'])
    print(data['relationships']['1']['node_id'])
# this will include the embeddings of the node along with metadata and text
chroma_collection.get(ids=ids[0],include=['embeddings', 'metadatas', 'documents'])

# we can also filter the collection
chroma_collection.get(ids=ids, where={'file_size': {'$gt': 75040}}, 
   where_document={'$contains': 'paul'}, include=['metadatas', 'documents'])

Consultas

image

from llama_index.llms.openai import OpenAI

from llama_index.core.retrievers import VectorIndexRetriever

from llama_index.core import get_response_synthesizer

from llama_index.core.response_synthesizers.type import ResponseMode

from llama_index.core.query_engine import RetrieverQueryEngine

from llama_index.core.chat_engine import (ContextChatEngine, 
CondenseQuestionChatEngine, CondensePlusContextChatEngine)

from llama_index.core.storage.chat_store import SimpleChatStore

from llama_index.core.memory import ChatMemoryBuffer

from llama_index.core import PromptTemplate

from llama_index.core.chat_engine.types import ChatMode

from llama_index.core.llms import ChatMessage, MessageRole
from llama_index.core import ChatPromptTemplate

Ahora podemos crear un índice desde el almacén de vectores. Un índice es una estructura de datos que facilita la recuperación rápida del contexto relevante para la consulta de un usuario.

# define the index
index = VectorStoreIndex.from_vector_store(vector_store=vector_store)

# define a retriever
retriever = VectorIndexRetriever(index=index, similarity_top_k=3)

En el código anterior, el recuperador recupera los 3 nodos principales similares a la consulta que proporcionamos.

Si queremos que el LLM responda la consulta basándose únicamente en el contexto proporcionado y nada más, podemos utilizar las indicaciones personalizadas en consecuencia.

qa_prompt_str = (
    "Context information is below.\n"
    "---------------------\n"
    "{context_str}\n"
    "---------------------\n"
    "Given the context information and not prior knowledge, "
    "answer the question: {query_str}\n"
)
chat_text_qa_msgs = [
    ChatMessage(role=MessageRole.SYSTEM,
 content=("Only answer the question, if the question is answerable with the given context. \
        Otherwise say that question can't be answered using the context"),
                ),
    ChatMessage(role=MessageRole.USER, content=qa_prompt_str)]

text_qa_template = ChatPromptTemplate(chat_text_qa_msgs)

Ahora podemos definir el sintetizador de respuestas, que pasa el contexto y consulta al LLM para obtener la respuesta. También podemos agregar un contador de tokens como administrador de devolución de llamadas para realizar un seguimiento de los tokens utilizados.

gpt_3_5 = OpenAI(model = 'gpt-3.5-turbo')

response_synthesizer = get_response_synthesizer(llm = gpt_3_5, response_mode=ResponseMode.COMPACT, 
                                                text_qa_template=text_qa_template, 
                                                callback_manager=CallbackManager([token_counter]))

Ahora podemos combinar el recuperador y el sintetizador de respuesta como un motor de consulta que acepta la consulta.

query_engine = RetrieverQueryEngine(
    retriever=retriever,
    response_synthesizer=response_synthesizer)

# ask a query
Response = query_engine.query("who is paul graham?")

# response text
Response.response

Para saber qué texto se utiliza para generar esta respuesta, podemos utilizar el siguiente código

for i, node in enumerate(Response.source_nodes):
    print(f"text of the node {i}")
    print(node.text)
    print("------------------------------------\n")

De manera similar, podemos probar diferentes motores de consulta.

Charlando

Si queremos conversar con nuestros datos, necesitamos almacenar las consultas anteriores y las respuestas en lugar de realizar consultas aisladas.

chat_store = SimpleChatStore()

chat_memory = ChatMemoryBuffer.from_defaults(token_limit=5000, chat_store=chat_store, llm=gpt_3_5)

system_prompt = "Answer the question only based on the context provided"

chat_engine = CondensePlusContextChatEngine(retriever=retriever, 
              llm=gpt_3_5, system_prompt=system_prompt, memory=chat_memory)

En el código anterior, inicializamos chat_store y creamos el objeto chat_memory con un límite de token de 5000. También podemos proporcionar un system_prompt y otras indicaciones.

Luego, podemos crear un motor de chat incluyendo también retriever y chat_memory.

Podemos obtener la respuesta de la siguiente manera.

streaming_response = chat_engine.stream_chat("Who is Paul Graham?")

for token in streaming_response.response_gen:
    print(token, end="")

Podemos leer el historial de chat con el código dado.

for i in chat_memory.chat_store.store['chat_history']:
    print(i.role.name)
    print(i.content)

Ahora podemos guardar y restaurar chat_store según sea necesario.

chat_store.persist(persist_path="chat_store.json")
chat_store = SimpleChatStore.from_persist_path(
    persist_path="chat_store.json"
)

De esta manera, podemos crear aplicaciones RAG sólidas utilizando el marco LlamaIndex y probar varios recuperadores y reclasificadores avanzados.

Conclusión

El marco LlamaIndex ofrece una solución integral para crear aplicaciones LLM resistentes, lo que garantiza un manejo eficiente de datos, almacenamiento persistente y capacidades de consulta mejoradas. Es una herramienta valiosa para los desarrolladores que trabajan con modelos de lenguaje grandes. Las conclusiones clave de esta guía sobre LlamaIndex son:

  • El marco LlamaIndex permite canales sólidos de ingesta de datos, lo que garantiza el análisis organizado de documentos, la extracción de metadatos y la creación de incrustaciones, al tiempo que evita duplicados.
  • Al gestionar eficazmente los almacenes de documentos y vectores, LlamaIndex garantiza la coherencia de los datos y facilita la recuperación y el almacenamiento de incrustaciones de documentos y metadatos.
  • El marco admite la creación de índices y motores de consulta personalizados, lo que permite una rápida recuperación del contexto para las consultas de los usuarios e interacciones continuas a través de motores de chat.

Relacionado

Domine la ingeniería rápida avanzada con LangChain para modelos de lenguaje contextuales

La ingeniería rápida se ha vuelto fundamental para aprovechar los modelos de lenguaje grande (LLM) para diversas aplicaciones. Como todos saben, la ingeniería rápida básica cubre técnicas fundamentales. Sin embargo, avanzar hacia métodos más sofisticados nos permite crear modelos de lenguaje robustos, altamente efectivos y conscientes del contexto. Este artículo profundizará en múltiples técnicas avanzadas de ingeniería de avisos utilizando LangChain. He agregado ejemplos de código ¡SEGUIR LEYENDO!

Qwen2 es el LLM de código abierto de Alibaba Cloud

En los próximos años están surgiendo muchas empresas nuevas que lanzarán nuevos modelos de lenguajes grandes de código abierto. A medida que pasa el tiempo, estos modelos se acercan cada vez más a los modelos pagos de código cerrado. Estas empresas lanzan estos modelos en varios tamaños y se aseguran de conservar sus licencias para que cualquiera pueda utilizarlos comercialmente. Uno de esos grupos de modelos ¡SEGUIR LEYENDO!