Abrir en Google Colab Descargar notebook

RAG con índice documental y un SLM

En el ejemplo anterior vimos el corazón de RAG: embeddings, similitud y contexto. Sin embargo, utilizamos documentos de texto sencillos y generamos los embeddings de forma manual almacenandonos en una lista en memoria. Tal técnica no puede escalar a grandes repositorios de documentos como los que podemos encontrar en una organización.

En este ejemplo, vamos a dar un paso más hacia una arquitectura de trabajo más parecida a la que encontraríamos en una organización: documentos con metadatos, chunking con solapamiento, un índice vectorial y un modelo generativo que responde usando los fragmentos recuperados.

Preparación del ambiente

Instalemos las librerías necesarias desde el archivo de requerimientos asociado. En Google Colab puede ser necesario reiniciar la sesión después de la instalación si el entorno lo solicita.

[ ]:
!wget -q https://raw.githubusercontent.com/santiagxf/M72109/master/docs/document-understanding/rag-index-slm.txt
%pip install -r rag-index-slm.txt --quiet
WARNING: huggingface-hub 1.16.1 does not provide the extra 'inference'
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 11.9/11.9 MB 95.5 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.6/1.6 MB 95.7 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.1/1.1 MB 76.7 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 121.2/121.2 kB 13.9 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 51.0/51.0 kB 5.6 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 142.4/142.4 kB 19.3 MB/s eta 0:00:00
ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
ipython 7.34.0 requires jedi>=0.16, which is not installed.

Sobre LlamaIndex

LlamaIndex es un projecto de código abierto diseñado para conectar modelos de lenguaje (LLM) con tus propias fuentes de información. Permite a los desarrolladores crear aplicaciones avanzadas de IA que pueden buscar, resumir y razonar sobre documentos privados o empresariales, superando la limitación de conocimiento público de los modelos.

En este notebook, utilizaremos esta librería para construir una solución de RAG facilmente. No es necesario utilizar esta librería, simplemente la utilizamos porque facilita la utilización de la técnica.

Documentos de ejemplo

En una organización, los documentos no suelen vivir como una lista de textos dentro del código. Normalmente llegan como archivos en una carpeta compartida, un data lake, un bucket de objetos o un repositorio documental. Para acercarnos a ese patrón, descargaremos algunos documentos en español al sistema de archivos y luego los cargaremos con un lector de documentos de LlamaIndex.

Usaremos páginas públicas del curso como sustituto de documentos internos. La idea pedagógica es la misma: separar la ingesta documental del índice y conservar metadatos de procedencia.

[ ]:
!mkdir repositorio_documental
[ ]:
from pathlib import Path
from urllib.request import urlretrieve

repo_documental = Path("repositorio_documental")
repo_documental.mkdir(exist_ok=True)

documentos_fuente = {
    "lg-user-manual-23476.pdf": {
        "fuente": "lg-user-manual-23476.pdf",
        "marca": "lg",
    },
    "samsumg-user-manual-15223.pdf": {
        "fuente": "samsumg-user-manual-15223.pdf",
        "marca": "samsung",
    }
}

Chunking con metadatos

En documentos largos, un único embedding puede mezclar muchas ideas. Por eso dividimos cada documento en chunks más pequeños y con solapamiento. El solapamiento ayuda a no cortar una explicación justo en el límite entre dos fragmentos.

En este notebook usaremos una estrategia de chunking basada en oraciones. A grandes rasgos, intenta respetar límites naturales del texto, como oraciones o párrafos, y luego agrupa contenido hasta aproximarse al tamaño indicado. Sin embargo, vamos a repetir una pequeña porción del fragmento anterior en el siguiente para conservar contexto local de un fragmento al otro. Podemos pensar que esto evita que una definición, una advertencia o una instrucción quede separada de la frase que la explica.

Existen otras estrategias:

  1. Una opción simple es cortar por cantidad fija de caracteres o tokens; es rápida y predecible, pero puede romper frases importantes.

  2. Podemos cortar por estructura del documento, por ejemplo títulos, secciones, páginas, tablas o encabezados; suele producir chunks más interpretables, aunque requiere que el documento tenga estructura confiable.

  3. También hay estrategias semánticas, donde se agrupan oraciones según similitud o cambios de tema; pueden mejorar la calidad de retrieval, pero agregan costo computacional y más parámetros que ajustar.

El trade-off principal es entre granularidad y contexto.

  • Chunks muy pequeños pueden recuperar evidencia muy precisa, pero quizás no contienen suficiente información para responder.

  • Chunks muy grandes conservan más contexto, pero pueden diluir la señal semántica del embedding y ocupar más espacio en la ventana de contexto del modelo.

El solapamiento ayuda, aunque también aumenta la cantidad de chunks, el costo de embeddings y la posibilidad de recuperar fragmentos repetidos.

El role de los metadatos

Los metadatos son tan importantes como el texto. En este ejemplo conservamos fuente y marca, pero en un escenario real podríamos guardar página, sección, fecha de actualización, permisos de acceso, tipo de documento o área responsable. Estos metadatos permiten explicar de dónde salió una respuesta, aplicar filtros antes del retrieval, auditar resultados y evitar que un usuario reciba información de documentos que no debería consultar.

Proceso

Utilizaremos LlamaIndex para cargar todo el contenido dentro de un directorio y puego crear un indice vectorial.

Algunas cosas a notar:

  • Utilizamos diferentes Readers para leer diferentes extensiones de archivos.

  • Podemos filtrar por extensiones especificas.

  • Podemos agregar metadatos especificos que necesitemos.

[ ]:
from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import SentenceSplitter
from llama_index.readers.file import PDFReader

def metadata_documento(file_path):
    archivo = Path(file_path).name
    return documentos_fuente.get(archivo, {"fuente": archivo, "marca": "sin clasificar"})

lector = SimpleDirectoryReader(
    input_dir=str(repo_documental),
    required_exts=[".txt", ".pdf"],
    file_extractor={".pdf": PDFReader()},
    file_metadata=metadata_documento,
)
documentos_llama = lector.load_data()

Utilizamos un SentenceSplitter para realizar la separación.

[ ]:
splitter = SentenceSplitter(chunk_size=256, chunk_overlap=30)

Finalmente, LlamaIndex trabaja con la idea de nodos, los cuales son chunks con metadatos.

[ ]:
nodos = splitter.get_nodes_from_documents(documentos_llama)

len(documentos_llama), len(nodos)

WARNING:pypdf._reader:invalid pdf header: b'<!== '
WARNING:pypdf._reader:incorrect startxref pointer(1)
WARNING:pypdf._reader:parsing for Object Streams
WARNING:pypdf._reader:invalid pdf header: b'<!== '
WARNING:pypdf._reader:incorrect startxref pointer(3)
WARNING:pypdf._reader:parsing for Object Streams
(136, 325)

Podemos inspeccionar algunos de los nodos para que puede entender como se representan. Esto es a fines educativos solo:

[ ]:
import pandas as pd

chunks = pd.DataFrame(
    [
        {
            "fuente": nodo.metadata["fuente"],
            "marca": nodo.metadata["marca"],
            "texto": nodo.get_content(metadata_mode="none"),
        }
        for nodo in nodos
    ]
)

chunks.head()
fuente marca texto
0 lg-user-manual-23476.pdf lg Antes de utilizar la unidad, lea detenidamente...
1 lg-user-manual-23476.pdf lg 1 Guía de inicio\nGuía de inicio2\nGuía de ini...
2 lg-user-manual-23476.pdf lg El signo de exclamación dentro de \nun triángu...
3 lg-user-manual-23476.pdf lg Los orificios no deben obstruirse. El producto...
4 lg-user-manual-23476.pdf lg Para \nevitar la exposición directa al rayo lá...

Creando el índice vectorial

Ahora configuramos un modelo de embeddings multilingüe y construimos un índice vectorial.

Un índice vectorial almacena representaciones numéricas de los chunks para poder buscar por similitud semántica. En vez de preguntar si dos textos comparten exactamente las mismas palabras, preguntamos si sus embeddings apuntan a una zona parecida del espacio vectorial. Esto resulta especialmente útil en documentos reales, donde el usuario puede escribir «modo suspensión» y el manual puede hablar de «suspender el sistema» o de «estado de bajo consumo».

En este caso los embeddings se generan con sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2, un modelo multilingüe liviano. Cada chunk se transforma en un vector denso de números reales; LlamaIndex conserva ese vector asociado al texto original y a sus metadatos. Cuando luego llega una consulta, la consulta también se convierte en un embedding y se compara contra los vectores almacenados para seleccionar los chunks más similares.

[ ]:
from llama_index.core import Settings, VectorStoreIndex
from llama_index.embeddings.huggingface import HuggingFaceEmbedding

Settings.embed_model = HuggingFaceEmbedding(
    model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", device="cuda"
)
/usr/local/lib/python3.12/dist-packages/huggingface_hub/utils/_auth.py:94: UserWarning:
The secret `HF_TOKEN` does not exist in your Colab secrets.
To authenticate with the Hugging Face Hub, create a token in your settings tab (https://huggingface.co/settings/tokens), set it as secret in your Google Colab and restart your session.
You will be able to reuse this secret in all of your notebooks.
Please note that authentication is recommended but still optional to access public models or datasets.
  warnings.warn(

En este notebook el índice vive en memoria para mantener el ejemplo liviano. Esto significa que los vectores y las referencias a los nodos existen mientras dura la sesión de Python; si reiniciamos el runtime, debemos reconstruir el índice. Para aprender resulta cómodo porque no requiere servicios externos, credenciales ni configuración adicional.

En una implementación empresarial, el mismo patrón suele conectarse a un vector database o a un motor de búsqueda híbrida para persistencia, escalabilidad y control operativo. Plataformas como estas agregan capacidades que un índice en memoria no resuelve por sí solo: almacenamiento persistente, índices aproximados eficientes, filtros por metadatos, control de acceso, replicación, monitoreo, versionado y actualización incremental cuando cambian los documentos. El trade-off es que aumentan la complejidad de operación y obligan a pensar en seguridad, costos, latencia y gobierno del dato.

Note que el concepto no cambia: seguimos guardando pares del tipo (embedding, chunk, metadatos). Lo que cambia es dónde viven esos pares y qué garantías necesitamos alrededor de ellos.

[ ]:
indice = VectorStoreIndex(nodos, show_progress=True)
[ ]:
retriever = indice.as_retriever(similarity_top_k=3)

Recuperando evidencia

Antes de pedirle al modelo que redacte una respuesta, inspeccionemos qué recupera el índice. Este paso es clave: si el retrieval trae mala evidencia, el generador tendrá muy pocas chances de responder bien.

Esta idea está directamente conectada con dos conceptos importantes en aplicaciones empresariales: hallucinations y groundedness. Una alucinación ocurre cuando el modelo produce una respuesta plausible, pero no sustentada por la información disponible. Groundedness, en cambio, mide hasta qué punto la respuesta está apoyada en la evidencia recuperada. No alcanza con que la respuesta suene correcta; necesitamos poder señalar los fragmentos que justifican cada afirmación relevante.

¿Por qué resulta tan importante medirlo? Porque muchas aplicaciones de negocio no toleran respuestas creativas sobre políticas internas, manuales técnicos, contratos o procedimientos. Una respuesta grounded permite auditar el sistema, mostrar citas al usuario, detectar documentos faltantes y decidir cuándo conviene responder «no tengo evidencia suficiente». En general, una métrica de groundedness compara la respuesta final con los chunks usados como contexto y penaliza afirmaciones que no aparecen o no se infieren razonablemente de esa evidencia.

[ ]:
pregunta = "¿Que función cumple el Modo Suspensión?"
nodos_recuperados = retriever.retrieve(pregunta)

pd.DataFrame(
    [
        {
            "score": round(resultado.score or 0, 3),
            "fuente": resultado.metadata["fuente"],
            "marca": resultado.metadata["marca"],
            "texto": resultado.node.get_content(metadata_mode="none"),
        }
        for resultado in nodos_recuperados
    ]
)
score fuente marca texto
0 0.351 lg-user-manual-23476.pdf lg 16\nFuncionamiento\n4\nFuncionamiento \nDismin...
1 0.328 lg-user-manual-23476.pdf lg Funcionamiento 15\nFuncionamiento\n4\nFunciona...
2 0.314 samsumg-user-manual-15223.pdf samsung En las próximas secciones, se tratarán los mét...

Conectando un SLM para responder

Usaremos un modelo pequeño de tipo text-to-text. No esperamos la misma calidad que en un LLM grande, pero resulta suficiente para demostrar la separación de responsabilidades: el índice trae evidencia y el SLM redacta una respuesta condicionada por esa evidencia.

El rol del SLM (por las siglas en inglés de Small Language Model) no es memorizar todo el conocimiento de la organización. En un pipeline RAG bien diseñado, el conocimiento relevante llega en el contexto recuperado. El modelo se ocupa principalmente de leer esos fragmentos, seleccionar la información útil, resolver pequeñas ambigüedades y redactar una respuesta clara para el usuario.

Por eso, si el grounding es bueno, no siempre necesitamos un modelo con razonamiento extremadamente fuerte. Para preguntas factuales sobre manuales, políticas o procedimientos, la parte difícil suele ser encontrar la evidencia correcta. Un modelo más grande puede ayudar cuando la pregunta exige múltiples pasos de razonamiento, síntesis compleja o manejo de instrucciones muy largas, pero también implica mayor costo, latencia y riesgo operativo. En general, conviene empezar preguntando: ¿la respuesta está en los documentos y el retrieval la encuentra? Si la respuesta es sí, un SLM puede ser suficiente.

[ ]:
from huggingface_hub import notebook_login

notebook_login()

En este caso, estamos usando Gemma 2B:

[ ]:
import torch
from llama_index.llms.huggingface import HuggingFaceLLM
from transformers import BitsAndBytesConfig

Settings.llm = HuggingFaceLLM(
    tokenizer_name="google/gemma-2b-it",
    model_name="google/gemma-2b-it",
    model_kwargs={
        "dtype": torch.bfloat16,
    },
    device_map="auto"
)

Creamos un QueryEngine a partir del indice que creamos anteriormente. Este QueryEngine funciona de forma similar a un retriever, pero ademos administra la generación de embeddings de las consultas via el modelo de embeddings que configuramos.

Configuramos ademas el parámetro top_k de igual forma que configuramos el retriever anteriormente.

[ ]:
query_engine = indice.as_query_engine(similarity_top_k=3)

Verificamos que funciona.

[ ]:
pregunta_llama_index = "¿Que función cumple el Modo Suspensión?"
respuesta_llama_index = query_engine.query(pregunta_llama_index)

print("Pregunta:", pregunta_llama_index)
print("Respuesta:", respuesta_llama_index)

Pregunta: ¿Que función cumple el Modo Suspensión?
Respuesta:
El modo de suspensión almacena la información que no haya sido guardada en el disco duro, solo se almacena en la memoria del ordenador. Si se interrumpe el suministro de energía, se perderá la información.

Podemos inspeccionar los nodos que fueron utilizados para la respuesta:

[ ]:
pd.set_option('display.max_colwidth', None)

references_data = []
for i, node in enumerate(respuesta_llama_index.source_nodes, 1):
    references_data.append({
        "Score": f"{node.score:.3f}",
        "Source": node.metadata['fuente'],
        "Content": node.get_content(metadata_mode='none')
    })

references_df = pd.DataFrame(references_data)
display(references_df)

¿Como relaciona RAG la evidencia con la consulta?

Si recuerdan de cuando vimos few-shot learning, los grandes modelos de lenguaje son grandes generados de texto condicionado. Cuando generamos texto condicionando sobre ejemplos, obtenemos few-shot learning. Sin embargo, también podemos condicionar la generación de contenido basado en evidencia que requerimos que el modelo utilice para responde.

En el caso de la libraria llama-index, este condicionamiento está codificado dentro de la misma. Podemos inspeccionarlo:

[ ]:
print(query_engine._response_synthesizer.get_prompts()["text_qa_template"].default_template.template)

Context information is below.
---------------------
{context_str}
---------------------
Given the context information and not prior knowledge, answer the query.
Query: {query_str}
Answer:

Una pregunta fuera del alcance

Un sistema empresarial no debería inventar respuestas cuando la base documental no contiene evidencia. Un mecanismo simple es revisar el score de recuperación y responder explícitamente cuando no hay soporte suficiente. En producción este control suele complementarse con evaluación, reglas de negocio y monitoreo.

¿Cómo podríamos medir este comportamiento? Una práctica común es construir un conjunto de preguntas de evaluación con dos tipos de casos: preguntas dentro del alcance, donde sabemos qué documento contiene la respuesta, y preguntas fuera del alcance, donde esperamos una negativa explícita. Para las primeras medimos si el sistema recupera evidencia relevante y si la respuesta está grounded. Para las segundas medimos si el sistema evita responder con información inventada.

En forma simple, podemos observar el score de similitud de los chunks recuperados y definir umbrales. Sin embargo, los scores dependen del modelo de embeddings, del índice y del dominio, por lo que no conviene interpretarlos como probabilidades universales. Resulta mejor calibrarlos con ejemplos reales: revisar distribuciones de scores para preguntas correctas e incorrectas, medir falsos positivos cuando el sistema responde sin evidencia y falsos negativos cuando rechaza preguntas que sí tenían soporte.

También podemos evaluar a nivel de respuesta: pedir que el sistema cite fuentes, verificar que cada afirmación importante aparezca en los chunks recuperados y registrar casos donde el modelo dice «no hay información suficiente». En aplicaciones empresariales, esta medición es clave porque convierte el problema de confianza en algo observable: no solo miramos si la respuesta es fluida, sino si está justificada por evidencia autorizada.

[ ]:
pregunta_fuera_de_alcance = "¿Cuál es el menú de la cafetería de la oficina?"
respuesta_llama_index = query_engine.query(pregunta_fuera_de_alcance)

print("Pregunta:", pregunta_fuera_de_alcance)
print("Respuesta:", respuesta_llama_index)

Pregunta: ¿Cuál es el menú de la cafetería de la oficina?
Respuesta:
No hay información en el contexto sobre el menú de la cafetería de la oficina.

¿Qué faltaría para producción?

Este ejemplo sigue siendo pequeño, pero ya muestra las piezas principales de una arquitectura RAG más robusta:

  • Documentos almacenados en el sistema de archivos antes de la ingesta.

  • Carga documental con un procesador como SimpleDirectoryReader de LlamaIndex.

  • Ingesta separada de la consulta en línea.

  • Chunking explícito con solapamiento.

  • Conservación de metadatos de procedencia.

  • Índice vectorial para retrieval semántico.

  • SLM desacoplado del índice para generar la respuesta.

  • Guardas simples para evitar respuestas sin evidencia.

En un escenario real agregaríamos persistencia del índice, control de permisos por documento, versionado, monitoreo de latencia, evaluación de respuestas, reranking, búsqueda híbrida y pruebas con preguntas representativas del dominio.

Cierre

RAG no es solo llamar a un LLM con un texto adicional. Podemos pensarlo como un pipeline de información: preparar documentos, representarlos como embeddings, recuperar evidencia, construir un prompt controlado y generar una respuesta verificable. La calidad final depende tanto del generador como del índice y de la forma en que partimos los documentos.