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.

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

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.