RAG con LangChain desde cero: cargadores, fragmentos, embeddings, evaluación
Construir un sistema de preguntas y respuestas para documentos paso a paso: carga de archivos, fragmentación, embeddings, las fases de indexación y recuperación, y cómo calificar la recuperación y las respuestas.
Fuentes: Building a RAG System with LangChain
Un sistema que lee tus documentos y responde preguntas sobre ellos es una de las cosas más útiles que puedes crear con un LLM hoy en día. Este patrón se denomina RAG, o generación reforzada por recuperación de información. A continuación se detallan todas sus componentes, su función y cómo determinar si tu implementación es de calidad.
Qué es LangChain
LangChain no es un modelo ni una base de datos. Es un framework, más bien un adaptador universal. Existen decenas de proveedores de LLM con sus propias APIs, decenas de bases de datos vectoriales y decenas de herramientas para cargar documentos. Sin una capa intermedia, utilizar GPT-4 hoy y Claude mañana implica tener que reescribir la mitad de tu código. LangChain ofrece una interfaz consistente para todos ellos.
El nombre proviene de su segunda función. Una aplicación real de LLM rara vez consiste en una sola llamada al modelo. Es posible cargar un documento, resumirlo, aplicar otro prompt sobre el resumen y formatear el resultado. Una cadena conecta esos pasos en un pipeline. En el caso de RAG, la cadena comienza con la pregunta del usuario; esta se incrusta, el embedding impulsa una búsqueda en la base de datos y los resultados más relevantes se insertan en un template de prompt. Se llama al LLM con ese prompt, se analiza su respuesta y la respuesta procesada se envía de vuelta al usuario. LangChain proporciona los componentes necesarios para cada paso, por lo que no es necesario unir todo a mano.
Carga de documentos
Primero, los documentos deben ser ingresados, y no como bytes en bruto. Ya sea que los datos estén en formato PDF, página web, CSV, archivo de Word o carpeta con archivos markdown, un cargador los convierte en un objeto Document estándar con dos campos: page_content para el texto y metadata para indicar de dónde provienen (página, URL, cualquier información útil). Los metadatos son importantes más adelante, ya que permiten mostrar a los usuarios la fuente de una respuesta.
from langchain.document_loaders import PyPDFLoader
loader = PyPDFLoader("handbook.pdf")
documents = loader.load()
Eso genera un Documento por página del PDF, cada uno con su texto y su número de página. Esta es una costumbre que vale la pena adoptar desde temprano: siempre mantener el origen de un fragmento de texto adjunto a él, ya que cada paso posterior, desde la división en partes hasta citar una fuente en la respuesta final, depende de esa información. Si se cambia el cargador por otro tipo de archivo, la salida mantiene la misma estructura.
División en partes
Un documento podría ser un PDF de 200 páginas. Incluso con una ventana de contexto muy amplia, incluir libros completos en cada solicitud es un desperdicio, costoso y a menudo peor que proporcionar al modelo fragmentos bien enfocados. Los documentos largos se dividen en partes de unos pocos cientos de tokens, y solo las partes más relevantes llegan al LLM cuando alguien hace una pregunta.
Dos configuraciones son las más importantes. Tamaño del bloque se refiere a la longitud de cada fragmento: si es demasiado pequeño, se pierde el contexto; si es demasiado grande, se incluye texto irrelevante. Un punto de partida razonable son unos 500 tokens. Superposición indica cuánto comparten los bloques adyacentes. Sin superposición, una oración dividida entre dos bloques pierde su significado, mientras que con superposición ambos bloques pueden entender la conexión.
RecursiveCharacterTextSplitter es la opción habitual. Prefiere los límites naturales, probando primero los párrafos, luego las oraciones y después las palabras, recurriendo solo a la división en medio de una palabra como último recurso. También existen opciones especializadas: MarkdownHeaderTextSplitter para documentos con encabezados y TokenTextSplitter cuando se desea contar tokens reales en lugar de caracteres.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50
)
chunks = splitter.split_documents(documents)
El particionamiento del texto parece algo trivial, pero afecta enormemente su calidad. También es la configuración que la gente ajusta por última vez, aunque una mala división limita silenciosamente todo lo que sigue. Si los fragmentos están mal, ningún truco de recuperación podrá salvar al sistema.
Embeddings
Las computadoras no pueden preguntar “¿qué fragmento se parece a mi pregunta?” sobre texto en bruto. Un modelo de embeddings, también llamado codificador, convierte el texto en un vector, es decir, una lista de números, y su característica clave es que los textos con significados similares obtienen vectores similares.
Imagínese que cada texto recibe una dirección en un espacio con cientos o miles de dimensiones. Un perro ladrando y un cachorro aullando obtienen direcciones vecinas, mientras que una caída del mercado bursátil se encuentra en un lugar muy lejano. Comparar significados se convierte en geometría: ¿qué tan cerca están dos puntos?
Dos aclaraciones ayudan. Primero, el codificador es quien crea los vectores, no la base de datos vectorial. Chroma, Pinecone, Weaviate y Qdrant solo almacenan los vectores y los buscan rápidamente; ninguno de ellos entiende el lenguaje. La gente confunde constantemente esto. Segundo, el tamaño del vector depende del codificador. El modelo text-embedding-3-small de OpenAI genera 1536 dimensiones, mientras que un modelo de código abierto como all-MiniLM-L6-v2 produce 384. Cuantas más dimensiones haya, se capturan más matices, pero a costa de más almacenamiento y búsquedas más lentas.
Los embeddings reales son imposibles de visualizar, por lo que la gente los proyecta a 2D o 3D. Al incrustar un conjunto de palabras y proyectarlas, aparecen clústeres: los animales juntos, la fruta en otro lugar y los vehículos en otra esquina. Nadie le indicó al modelo esas categorías; aprendió de enormes cantidades de texto que “perro” y “gato” aparecen en contextos similares.
Eso es lo que hace que RAG funcione. Si un usuario pregunta qué dice la empresa sobre la licencia parental, el vector de la pregunta se sitúa cerca de fragmentos sobre “beneficios por licencia familiar” o “tiempo libre para nuevos padres”, aunque esas palabras difieren.
El proceso
Hay dos fases. La indexación se realiza una sola vez al configurar el sistema: se cargan los datos, se dividen en fragmentos, se incrustan cada uno de esos fragmentos y se almacenan los vectores correspondientes. Los nuevos documentos simplemente se incrustan y se añaden posteriormente. La recuperación tiene lugar con cada consulta: se incrusta la pregunta usando el mismo codificador, se buscan los fragmentos más cercanos en la base de datos vectorial, se incluyen junto con la pregunta en un prompt y se envían a un LLM, el cual basa su respuesta en los documentos proporcionados y no en conjeturas. Una sola base de datos vectorial sirve para ambas fases: se escribe en ella durante la indexación y se lee durante la recuperación. Esa es toda la arquitectura. Dado que la indexación es un costo único, la parte lenta (incrustar miles de fragmentos) no afecta al tiempo de espera del usuario; solo lo hacen la incrustación de la consulta, la búsqueda y la llamada al LLM.
En tiempo de ejecución participan cuatro componentes: el usuario, el código de tu aplicación, el recuperador y el LLM. El usuario envía una pregunta a tu código. Tu código solicita al recuperador los fragmentos relevantes, y este integra la pregunta y busca en la base de datos. Se devuelven los K fragmentos más similares. Tu código crea un prompt a partir de esos fragmentos junto con la pregunta original y lo envía al LLM, el cual genera una respuesta a partir del contexto proporcionado. Tu código devuelve esa respuesta.
LangChain abarca casi todo esto mediante dos abstracciones. LLM (o ChatModel) encapsula tu modelo, mientras que Retriever se encarga de las búsquedas en el almacén de vectores. Al conectarlos, el framework se ocupa del resto:
from langchain.chains import RetrievalQA
qa_chain = RetrievalQA.from_chain_type(
llm=ChatOpenAI(),
retriever=vectorstore.as_retriever(),
return_source_documents=True
)
response = qa_chain.invoke({"query": "What is our parental leave policy?"})
Ese es un sistema RAG funcional en unas pocas líneas, y es en ese punto cuando resulta tentador detenerse. No lo haga. Un sistema que responde aún no es un sistema en el que se pueda confiar.
Por qué evaluar
La trampa consiste en hacer una pregunta, obtener una respuesta plausible y considerar que ya está hecho. RAG falla de maneras sutiles: el recuperador puede extraer los fragmentos incorrectos, el LLM puede generar fantasías incluso con un buen contexto, y la división en fragmentos puede partir una respuesta por la mitad. La evaluación le indica qué parte está defectuosa: la recuperación, la generación o ambas, ya que cada una requiere una solución diferente. También le proporciona un criterio de referencia, ya que seguirá probando diferentes tamaños de fragmentos, codificadores y prompts; sin mediciones, cada cambio es solo una suposición.
Métricas de recuperación
Cuatro métricas estándar, cada una de las cuales le indica algo distinto:
- Recall@K: de los documentos que deberían haber aparecido, ¿cuántos se encuentran entre los K primeros? Con 4 documentos relevantes en el corpus y 3 de ellos entre los 5 primeros, el Recall@5 es de 0.75. Un alto nivel de recall significa que se han omitido pocos documentos.
- Precision@K: de los K resultados devueltos, ¿cuántos eran relevantes? Tres resultados buenos de cinco dan un valor de 0.60. Una alta precisión significa que hay poco ruido en los resultados.
- MRR (Mean Reciprocal Rank): solo cuenta la posición del primer resultado correcto. Un hallazgo en la posición 2 obtiene una puntuación de 0.50, mientras que uno en la posición 1 obtiene 1.0. Este indicador recompensa la aparición rápida de una respuesta correcta.
- nDCG (Normalized Discounted Cumulative Gain): es el indicador más refinado. Evalúa cuán relevante es cada documento y su posición en la lista; por lo tanto, los elementos relevantes con mayor ranking cuentan más, y se compara con el mejor ranking posible.
En la práctica, se utilizan dos métricas: una para evaluar la exhaustividad (Recall) y otra para medir la calidad del ordenamiento (MRR o nDCG).
Calidad de la respuesta
Incluso una recuperación perfecta puede generar una respuesta deficiente, por lo que es necesario calificar las respuestas por separado. Se parte del mismo conjunto de pruebas seleccionadas, donde cada entrada combina una pregunta con los fragmentos que deberían aparecer y una respuesta de referencia. Las cuatro métricas de recuperación mencionadas anteriormente abordan la primera mitad del proceso. En cuanto a las respuestas en sí, se califican según otras dimensiones:
- Fidelidad: ¿está basada en el contexto recuperado o es inventada?
- Relevancia: ¿aborda la pregunta?
- Exactitud: ¿cómo se compara con la respuesta correcta?
- Exhaustividad: ¿incluye todo lo que debería incluir?
El método habitual consiste en utilizar un segundo LLM como juez. Se le proporciona la pregunta, la respuesta y la verdad real, y se le pide que evalúe cada dimensión. No es perfecto, pero permite escalar.
El conjunto de pruebas
Cada paso de evaluación anterior se basa en algo: un conjunto de preguntas con respuestas correctas conocidas.
Este es el elemento más subestimado. Sin él no se puede evaluar nada. Busca entre 50 y unos pocos cientos de ejemplos, cada uno con una pregunta realista, los fragmentos que contienen la respuesta y una respuesta ideal. Las fuentes pueden ser consultas reales de usuarios si el sistema está en funcionamiento, o bien ejemplos sintéticos: alimenta tus documentos a un LLM para que genere pares de pregunta y respuesta, y luego revísalos y corrígelos.
Guarda este conjunto. Una vez que exista, cada cambio se evaluará en comparación con él, y así sabrás si ese cambio fue útil.
Dónde se vuelve más difícil
En resumen, RAG consiste en dividir los documentos en fragmentos, vectorizar cada uno de ellos, almacenarlos, incrustar las consultas de los usuarios, encontrar los vectores más cercanos y pasar esos fragmentos a un LLM. LangChain proporciona componentes estándar para cada paso, y las pruebas demuestran su eficacia. Un producto real plantea preguntas más complejas: el tamaño adecuado de los fragmentos para los documentos, qué codificador es el mejor, cómo mantener el índice actualizado, cuándo realizar una nueva clasificación y si utilizar una búsqueda híbrida. Esas decisiones distinguen una demostración básica de algo realmente útil, pero se basan en los fundamentos mencionados anteriormente.