RAG versus relleno de contexto: por qué la recuperación selectiva es más eficiente y confiable que volcar todos los datos en el mensaje

Las grandes ventanas de contexto han aumentado drásticamente la cantidad de información que los modelos de lenguaje modernos pueden procesar en un solo mensaje. Con modelos capaces de manejar cientos de miles, o incluso millones, de tokens, es fácil asumir que la generación de recuperación aumentada (RAG) ya no es necesaria. Si puede incluir una base de código completa o una biblioteca de documentación en la ventana contextual, ¿por qué crear un canal de recuperación?

La distinción clave es que una ventana de contexto define cuánto puede ver el modelo, mientras que RAG determina lo que debe ver el modelo. Una ventana grande aumenta la capacidad, pero no mejora la relevancia. RAG filtra y selecciona la información más importante antes de que llegue al modelo, mejorando la relación señal-ruido, la eficiencia y la confiabilidad. Los dos enfoques resuelven problemas diferentes y no se reemplazan entre sí.

En este artículo, comparamos ambas estrategias directamente. Utilizando la API de OpenAI, evaluamos la generación aumentada de recuperación frente al relleno de contexto de fuerza bruta en el mismo corpus de documentación. Medimos el uso de tokens, la latencia y el costo, y demostramos cómo ocultar información crítica dentro de mensajes grandes puede afectar el rendimiento del modelo. Los resultados resaltan por qué las grandes ventanas contextuales complementan RAG en lugar de reemplazarlo.

Instalando las dependencias

importar os importar hora importar textwrap importar numpy como np importar tiktoken desde openai importar OpenAI desde getpass importar getpass os.environ["OPENAI_API_KEY"] = getpass('Ingrese la clave API de OpenAI: ') cliente = OpenAI()

Usamos text-embedding-3-small como modelo de incrustación para convertir documentos y consultas en representaciones vectoriales para una recuperación semántica eficiente. Para la generación y el razonamiento, utilizamos gpt-4o, y la contabilidad de tokens se maneja mediante su codificación tiktoken correspondiente para medir con precisión el tamaño y el costo del contexto.

EMBED_MODEL = "incrustación de texto-3-pequeño" CHAT_MODEL = "gpt-4o" ENC = tiktoken.encoding_for_model("gpt-4o")

Creando el corpus del documento

Este corpus sirve como fuente de recuperación para nuestro punto de referencia. En la configuración RAG, se generan incrustaciones para cada documento y se recuperan fragmentos relevantes en función de la similitud semántica. En la configuración de relleno de contexto, todo el corpus se inyecta en el mensaje. Debido a que los documentos contienen cláusulas numéricas específicas (por ejemplo, límites de tiempo, topes de tarifas, ventanas de reembolso), son muy adecuados para probar la precisión de la recuperación, la densidad de la señal y el efecto "Perdido en el medio" en condiciones de contexto amplio.

El corpus consta de 10 documentos de políticas estructurados que suman aproximadamente 650 tokens, y cada documento oscila entre 54 y 83 tokens. Este tamaño mantiene el conjunto de datos manejable y al mismo tiempo refleja la diversidad y densidad de un conjunto de documentación empresarial realista.

Aunque es relativamente pequeño, el corpus incluye cláusulas numéricas, reglas condicionales y declaraciones de cumplimiento, lo que lo hace adecuado para evaluar la precisión de la recuperación, la precisión del razonamiento y la eficiencia de los tokens. Proporciona un entorno controlado para comparar la recuperación selectiva basada en RAG con el relleno de contexto completo sin introducir ruido externo.

def count_tokens(text: str) -> int: return len(ENC.encode(text)) DOCS = [ { "id": 1, "title": "Política de reembolso", "content": ( "Los clientes pueden solicitar un reembolso completo dentro de los 30 días posteriores a la compra. " "Los reembolsos se procesan dentro de 5 a 7 días hábiles al método de pago original. " "Los productos digitales no son reembolsables una vez que se ha accedido al enlace de descarga. " "Las cancelaciones de suscripciones se detienen cargos futuros, pero no activan reembolsos automáticos " "para el ciclo de facturación actual a menos que la cancelación se realice dentro de las 48 horas posteriores a la renovación." ) }, { "id": 2, "title": "Información de envío", "content": ("El envío estándar demora de 5 a 7 días hábiles. El envío exprés se entrega en 2 a 3 días hábiles. " "Los pedidos superiores a $50 califican para envío estándar gratuito dentro de los EE. UU. continentales". "El envío internacional está disponible a 30 países y demora 10 a 21 días hábiles. " "Los números de seguimiento se envían por correo electrónico dentro de las 24 horas posteriores al envío." ) }, { "id": 3, "title": "Seguridad de la cuenta", "content": (" "La autenticación de dos factores (2FA) se puede habilitar desde la pestaña Seguridad en la configuración de la cuenta. " "Las contraseñas deben tener al menos 12 caracteres e incluir una letra mayúscula, un número " "y un carácter especial. Las sesiones activas caducan después de 30 días de inactividad. " "Los intentos de inicio de sesión sospechosos activan un bloqueo automático de la cuenta y un correo electrónico de reinicio." ) }, { "id": 4, "title": "Límites de tasa de API", "content": ( "Nivel gratuito: 100 solicitudes por día, máximo 10 solicitudes por minuto. " "Nivel Pro: 10 000 solicitudes por día, máximo 200 solicitudes por minuto. " "Nivel empresarial: solicitudes ilimitadas, hasta 1000 por minuto". las respuestas incluyen encabezados X-RateLimit-Remaining y X-RateLimit-Reset. " "Exceder los límites devuelve HTTP 429 con un encabezado Retry-After." ) }, { "id": 5, "title": "Privacidad de datos y GDPR", "content": ( "Todos los datos del usuario se cifran en reposo usando AES-256 y en tránsito usando TLS 1.3. " "Nunca vendemos ni alquilamos datos personales a terceros". cumple totalmente con GDPR y CCPA. " "Las solicitudes de eliminación de datos se procesan dentro de las 72 horas " "Los usuarios pueden exportar todos sus datos en formato JSON o CSV desde la sección Privacidad." ) }, { "id": 6, "title": "Ciclos de facturación y suscripción", "content": ("Las suscripciones se renuevan automáticamente el mismo día calendario cada mes. " "Los planes anuales ofrecen un descuento del 20 % en comparación con la facturación mensual. " "Las facturas se envían. por correo electrónico 3 días antes de cada renovación. " "Los pagos fallidos se reintentan tres veces durante 7 días antes de que la cuenta sea degradada." ) }, { "id": 7, "title": "Formatos de archivo admitidos", "content": ( "Formatos de carga admitidos: PDF, DOCX, XLSX, PPTX, PNG, JPG, WebP, MP4, MOV. " "El tamaño máximo de archivo individual es 100 MB. " "Las cargas por lotes admiten hasta. 50 archivos simultáneamente. " "Los archivos se analizan en busca de virus al cargarlos y se ponen en cuarentena si se detectan amenazas." ) }, { "id": 8, "title": "Certificaciones de cumplimiento", "content": ( "La plataforma cuenta con la certificación SOC 2 Tipo II, que se renueva anualmente. " "El cumplimiento de ISO 27001 se mantiene con auditorías internas trimestrales. " "Un acuerdo de asociado comercial (BAA) de HIPAA está disponible para los clientes de atención médica en el plan Enterprise. " "Cumplimiento de PCI-DSS Nivel 1. cubre todos los flujos de procesamiento de pagos." ) }, { "id": 9, "title": "SLA y garantías de tiempo de actividad", "content": ( "Enterprise SLA garantiza un 99,9 % de tiempo de actividad mensual (≤ 43 minutos de tiempo de inactividad/mes). " "Las ventanas de mantenimiento programadas ocurren todos los domingos entre las 02:00 y las 04:00 UTC. " "Los incidentes no planificados se comunican a través de status.example.com dentro de los 15 minutos". "Los incumplimientos del SLA se compensan con créditos de servicio aplicados a la siguiente factura." ) }, { "id": 10, "title": "Política de cancelación", "content": ("Los usuarios pueden cancelar en cualquier momento desde la pestaña Suscripción en la configuración de la cuenta. " "Los titulares del plan anual reciben un reembolso prorrateado por los meses no utilizados si se cancela dentro de los 30 días posteriores a la renovación. " "La cancelación entra en vigencia al final del período de facturación actual; el acceso continúa hasta entonces. " "Reactivación dentro de 90 días de cancelación restauran todos los datos históricos." ) }, ]
total_tokens = suma(count_tokens(d["content"]) para d en DOCS) print(f"Corpus: {len(DOCS)} documentos | {total_tokens} tokens totaln") para d en DOCS: print(f" [{d['id']:02d}] {d['title']:<35} ({count_tokens(d['content'])} tokens)")

Construyendo el índice de incrustación

Generamos incrustaciones de vectores para los 10 documentos utilizando el modelo text-embedding-3-small y los almacenamos en una matriz NumPy. Cada documento se convierte en un vector float32 de 1536 dimensiones, lo que produce un índice con forma (10, 1536).

Todo el paso de indexación se completa en 1,82 segundos, lo que demuestra cuán liviana es la indexación semántica a esta escala. Esta matriz vectorial ahora actúa como nuestra capa de recuperación, lo que permite una búsqueda rápida de similitudes durante el flujo de trabajo RAG en lugar de escanear texto sin formato en el momento de la inferencia.

def embed_texts(texts: list[str]) -> np.ndarray: """Llame a la API de OpenAI Embeddings y devuelva una matriz float32 (N, 1536).""" respuesta = client.embeddings.create(model=EMBED_MODEL, input=texts) return np.array([item.embedding for item in Response.data], dtype=np.float32) print("Construyendo índice … ", end="", Flush=True) t0 = time.perf_counter() corpus_texts = [d["content"] for d in DOCS] index = embed_texts(corpus_texts) # forma: (10, 1536) transcurrido = time.perf_counter() – t0 print(f"hecho en {elapsed:.2f}s | forma de índice: {índice.forma}")

Recuperación y ayudantes rápidos

Las siguientes funciones implementan el proceso de comparación completo entre RAG y el relleno de contexto.

retrieve() incorpora la consulta del usuario, calcula la similitud del coseno mediante un producto escalar con respecto al índice precalculado y devuelve los k documentos más relevantes con puntuaciones de similitud. Debido a que text-embedding-3-small genera vectores de norma unitaria, el producto escalar representa directamente la similitud del coseno, lo que mantiene la recuperación simple y eficiente. build_rag_prompt() construye un mensaje enfocado utilizando solo los fragmentos recuperados, asegurando una alta densidad de señal y un contexto irrelevante mínimo. build_stuffed_prompt() construye un mensaje de fuerza bruta inyectando todo el corpus en el contexto, simulando el enfoque de "solo usar toda la ventana". call_llm() envía el mensaje a gpt-4o, mide la latencia y captura el uso del token, lo que nos permite comparar directamente el costo, la velocidad y la eficiencia entre las dos estrategias.

Juntos, estos ayudantes crean un entorno controlado para comparar la precisión de la recuperación con la capacidad del contexto sin procesar.

def retrieve(query: str, k: int = 3) -> list[dict]: """ Incrusta la consulta, calcula la similitud del coseno con el índice y devuelve los k documentos top-k con sus puntuaciones. text-embedding-3-small devuelve vectores de norma unitaria, por lo que el producto escalar ES similitud del coseno, no se necesita normalización adicional. """ q_vec = embed_texts([query])[0]# forma: (1536,) puntuaciones = índice @ q_vec # producto escalar = similitud coseno top_idx = np.argsort(scores)[::-1][:k] # índices top-k, mayor primer retorno [{"doc": DOCS[i], "score": float(scores[i])} for i in top_idx] def build_rag_prompt(query: str, chunks: list[dict]) -> str: """Crea un mensaje enfocado solo a partir de los fragmentos recuperados.""" context_parts = [ f"[Fuente: {c['doc']['title']}]n{c['doc']['content']}" for c en fragmentos ] context = "nn—nn".join(context_parts) return ( f"Eres un asistente de soporte útil. " f"Responda la siguiente pregunta usando el contexto proporcionado. " f"Sea específico y directo.nn" f"CONTEXTO:n{context}nn" f"PREGUNTA: {consulta}" ) def build_stuffed_prompt(query: str) -> str: """Construya un mensaje que vuelque todo el corpus en el contexto.""" context_parts = [ f"[Fuente: {d['title']}]n{d['content']}" for d in DOCS ] context = "nn—nn".join(context_parts) return ( f"Eres un asistente de soporte útil. " f"Responda la siguiente pregunta utilizando el contexto proporcionado. " f"Sea específico y directo.nn" f"CONTEXTO:n{context}nn" f"PREGUNTA: {consulta}" ) def call_llm(prompt: str) -> tuple[str, float, int, int]: """Returns (answer, latency_ms, input_tokens, output_tokens).""" t0 = time.perf_counter() res = client.chat.completions.create( model = CHAT_MODEL, mensajes = [{"role": "user", "content": Prompt}], temperatura = 0, ) latency_ms = (time.perf_counter() – t0) * 1000 respuesta = res.choices[0].message.content.strip() devuelve respuesta, latency_ms, res.usage.prompt_tokens, res.usage.completion_tokens

Comparando los enfoques

Este bloque ejecuta una comparación directa, en paralelo, entre la generación aumentada de recuperación (RAG) y el relleno de contexto de fuerza bruta utilizando la misma consulta de usuario. En el enfoque RAG, el sistema primero recupera los tres documentos más relevantes según la similitud semántica, crea un mensaje enfocado utilizando solo esos fragmentos y luego envía ese contexto condensado al modelo. También imprime puntuaciones de similitud, recuentos de tokens y latencia, lo que nos permite observar cuánto contexto se requiere realmente para responder la pregunta de manera efectiva.

Por el contrario, el enfoque de relleno de contexto construye un mensaje que incluye los 10 documentos, independientemente de su relevancia, y envía el corpus completo al modelo. Al medir los tokens de entrada, los tokens de salida y el tiempo de respuesta para ambos métodos en condiciones idénticas, aislamos la diferencia arquitectónica entre la recuperación selectiva y la carga de fuerza bruta. Esto hace que las compensaciones en eficiencia, costo y desempeño sean concretas en lugar de teóricas.

QUERY = "Cómo solicito un reembolso y cuánto tiempo lleva"
DIVISOR = "─" * 65 print(f"n{'='*65}") print(f" CONSULTA: {CONSULTA}") print(f"{'='*65}n") # ── TRAPO ─────────────────────────────────── ─────────────────────────────────── print("[ ENFOQUE 1 ] RAG (recuperar y luego razonar)") print(DIVIDER) fragmentos = recuperar(CONSULTA, k=3) rag_prompt = build_rag_prompt(CONSULTA, fragmentos) print(f"Top-{len(fragmentos)} fragmentos recuperados:") para c en fragmentos: vista previa = c["doc"]["contenido"][:75].replace("n", " ") print(f" • {c['doc']['title']:<40} similitud: {c['score']:.4f}") print(f" "{preview}…."") print(f"nTotal de tokens enviados a LLM: {count_tokens(rag_prompt)}n") rag_answer, rag_latency, rag_in, rag_out = call_llm(rag_prompt) print(f"Respuesta:n{textwrap.fill(rag_answer, 65)}") print(f"nTokens → entrada: {rag_in:>6,} | salida: {rag_out:>4,} | total: {rag_in+rag_out:>6,}") print(f"Latencia → {rag_latency:,.0f} msn") # ── Enfoque 2: relleno de contexto ─────────────────────── ─────────────────────── print("[ ENFOQUE 2 ] Relleno de contexto (deseche todo, luego razone)") print(DIVIDER) stuffed_prompt = build_stuffed_prompt(QUERY) print(f"Enviando todos los documentos {len(DOCS)} ({count_tokens(stuffed_prompt):,} tokens) al LLM …n") cosas_respuesta, cosas_latencia, cosas_in, cosas_fuera = call_llm(stuffed_prompt) print(f"Respuesta:n{textwrap.fill(stuff_answer, 65)}") print(f"nTokens → entrada: {stuff_in:>6,} | salida: {stuff_out:>4,} | total: {stuff_in+stuff_out:>6,}") print(f"Latencia → {stuff_latency:,.0f} msn")

Los resultados muestran que ambos enfoques producen una respuesta correcta y casi idéntica, pero el perfil de eficiencia es muy diferente.

Con RAG, solo se recuperaron los tres documentos más relevantes, lo que resultó en 278 tokens enviados al modelo (285 tokens de aviso reales). El uso total de tokens fue de 347 y la latencia de respuesta fue de 783 ms. Los fragmentos recuperados priorizaron claramente la Política de reembolso, que contiene directamente la respuesta, mientras que los dos documentos restantes eran coincidencias secundarias basadas en similitud semántica.

Con el relleno de contexto, los 10 documentos se inyectaron en el mensaje, lo que aumentó el tamaño de entrada a 775 tokens y el uso total a 834 tokens. La latencia casi se duplicó a 1518 ms. A pesar de procesar más del doble de los tokens de entrada, el modelo produjo esencialmente la misma respuesta.

La conclusión clave no es que el relleno falle (funciona a pequeña escala) sino que es ineficiente. RAG logró el mismo resultado con menos de la mitad de los tokens y aproximadamente la mitad de la latencia. A medida que el tamaño del corpus crece de 10 documentos a miles, esta brecha se agrava dramáticamente. Lo que parece inofensivo con 768 tokens se vuelve prohibitivamente caro y lento con más de 500.000 tokens. Este es el argumento económico y arquitectónico a favor de la recuperación: optimizar la señal antes del razonamiento.

token_ratio = cosas_in / rag_in latency_ratio = cosas_latencia / rag_latency COST_PER_1M = 2.5 rag_cost = (rag_in / 1_000_000) * COST_PER_1M cosas_cost = (cosas_in / 1_000_000) * COST_PER_1M print(f"n{'='*65}") print(f" RESUMEN DE CABEZA A CABEZA") print(f"{'='*65}") print(f" {'Metric':<30} {'RAG':>10} {'Stuffing':>10}") print(f" {DIVIDER}") print(f" {'Tokens de entrada':<30} {rag_in:>10,} {stuff_in:>10,}") print(f" {'Salida tokens':<30} {rag_out:>10,} {stuff_out:>10,}") print(f" {'Latencia (ms)':<30} {rag_latency:>10,.0f} {stuff_latency:>10,.0f}") print(f" {'Costo por llamada (USD)':<30} ${rag_cost:>9.6f} ${stuff_cost:>9.6f}") print(f" {DIVIDER}") print(f" {'Multiplicador de token':<30} {'1x':>10} {token_ratio:>9.1f}x") print(f" {'Multiplicador de latencia':<30} {'1x':>10} {latency_ratio:>9.1f}x") print(f" {'Costo multiplicador':<30} {'1x':>10} {token_ratio:>9.1f}x") print(f"{'='*65}")

La comparación directa hace explícitas las compensaciones. El relleno de contexto requirió 2,7 veces más tokens de entrada, casi 2 veces la latencia y 2,7 ​​veces el costo por llamada, al tiempo que producía esencialmente la misma respuesta que RAG. El recuento de tokens de salida se mantuvo similar, lo que significa que el gasto adicional provino enteramente de un contexto innecesario.

Efecto perdido en el medio

Para demostrar el efecto "Perdido en el medio", creamos una configuración controlada donde una actualización crítica de la política (la aguja) establece que los clientes empresariales con un BAA HIPAA activo tienen derecho a un período de reembolso de 90 días en lugar de los 30 días estándar. Esta cláusula responde directamente a la consulta, pero está intencionalmente enterrada dentro de aproximadamente 800 tokens de texto de relleno irrelevante diseñado para simular un mensaje inflado y sobrecargado. Al preguntar: "¿Cuál es la ventana de reembolso para los clientes empresariales con un BAA HIPAA?", podemos probar si el modelo extrae de manera confiable la cláusula oculta cuando está rodeada de ruido, lo que ilustra cómo el contexto grande por sí solo no garantiza una atención o recuperación precisa.

NEEDLE = ( "ACTUALIZACIÓN DE LA POLÍTICA: Los clientes empresariales con un HIPAA BAA activo " "tienen derecho a un período de reembolso completo de 90 días, no al período estándar de 30 días." ) # ~800 tokens de relleno irrelevante para simular un documento inflado FILLER = ( "Esta sección cubre las pautas generales y las mejores prácticas de la plataforma. " "Los usuarios deben asegurarse de leer toda la documentación antes de continuar". "Las opciones de configuración pueden variar según el nivel del plan seleccionado". "Consulte la guía de incorporación para obtener instrucciones paso a paso. " "El soporte está disponible las 24 horas, los 7 días de la semana a través de chat, correo electrónico y teléfono para usuarios empresariales. " ) * 30
NEEDLE_QUERY = "¿Cuál es el plazo de reembolso para los clientes empresariales con un BAA de HIPAA?"
def run_lost_in_middle(): print(f"n{'='*65}") print(" 'EXPERIMENTO 'PERDIDO EN EL MEDIO'") print(f"{'='*65}") print(f"Query: {NEEDLE_QUERY}") print(f"Needle: "{NEEDLE[:65]}…."n") # Escenario A: Enfocado (simula un buen RAG recuperación) Prompt_a = ( f"Eres un asistente de soporte útil. " f"Responde la pregunta usando el contexto siguiente.nn" f"CONTEXTO:n{NEEDLE}nn" f"PREGUNTA: {NEEDLE_QUERY}" ) # Escenario B: Enterrado (simula relleno; la aguja está en medio del ruido) enterrado = f"{FILLER}nn{NEEDLE}nn{FILLER}" Prompt_b = ( f"Eres un asistente de soporte útil. " f"Responde la pregunta usando el contexto siguiente.nn" f"CONTEXTO:n{enterrado}nn" f"PREGUNTA: {NEEDLE_QUERY}" ) print(f"[ A ] Contexto enfocado ({count_tokens(prompt_a):,} entrada tokens)") ans_a, _, _, _ = call_llm(prompt_a) print(f"Respuesta: {ans_a}n") print(f"[ B ] Aguja enterrada en el relleno ({count_tokens(prompt_b):,} tokens de entrada)") ans_b, _, _, _ = call_llm(prompt_b) print(f"Respuesta: {ans_b}n") print("─" * 65)

En este experimento, ambas configuraciones arrojan la respuesta correcta (90 días), pero la diferencia en el tamaño del contexto es significativa. La versión enfocada requirió solo 67 tokens de entrada, entregando la respuesta correcta con un contexto mínimo. Por el contrario, la versión rellena requirió 3729 tokens de entrada, más de 55 veces más entradas, para llegar a la misma respuesta.

A esta escala, el modelo aún pudo localizar la cláusula enterrada. Sin embargo, el resultado resalta un principio importante: la corrección por sí sola no es la métrica; la eficiencia y la confiabilidad sí lo son. A medida que el tamaño del contexto aumenta aún más, la difusión de la atención, la latencia y los costos aumentan, y la precisión de la recuperación se vuelve cada vez más crítica. El experimento muestra que las ventanas de contexto grandes aún pueden tener éxito, pero lo hacen con un gasto computacional dramáticamente mayor y con mayor riesgo a medida que los documentos se vuelven más largos y complejos.

Soy graduado en ingeniería civil (2022) de Jamia Millia Islamia, Nueva Delhi, y tengo un gran interés en la ciencia de datos, especialmente las redes neuronales y su aplicación en diversas áreas.