Skip to content
This page has been auto-translated and may contain errors.View in English

RAG

docs.scrimba.com

Primero, el nombre. RAG es la abreviatura de generación aumentada por recuperación: buscas el texto relevante tú mismo y se lo pasas al modelo en el momento de la pregunta, para que responda basándose en los hechos que suministraste en lugar de hacerlo desde la memoria congelada. Este capítulo es sobre cómo funciona ese ciclo de búsqueda-y-respuesta y dónde falla.

El patrón recuperar-luego-generar

Un modelo no conoce los documentos de ayuda de tu empresa, los detalles de tu producto, o nada que haya sucedido después de que fue entrenado. Pregúntale al respecto y o bien admitirá que no puede ayudarte o, peor, inventará algo que suena correcto. RAG arregla esto pasándole al modelo el texto relevante en el momento de la pregunta.

La idea se materializa una vez que tienes embeddings, el truco que convierte texto en vectores que puedes comparar por cercanía (la similitud del coseno mide qué tan cerca apuntan dos vectores). La generación aumentada por recuperación son dos pasos enganchados a una llamada normal al modelo:

  1. Recuperar. Toma la pregunta del usuario, busca en tus documentos los fragmentos más relevantes y recopila las coincidencias principales.
  2. Generar. Pon esos fragmentos en la solicitud como contexto, luego pide al modelo que responda usando solo ese contexto.

La capacidad general del modelo para el lenguaje hace la escritura; el texto que recuperaste suministra los hechos. Obtienes respuestas fluidas que están fundamentadas en tu contenido y pueden mantenerse actuales, porque controlas lo que entra.

Ayuda ver por qué esto funciona tan bien, en términos del ciclo de predicción. Sin RAG, le estás pidiendo al modelo que recuerde un hecho de los patrones congelados en sus parámetros, que puede no tener, lo que lleva a una adivinanza segura de sí misma. Con RAG, el hecho está justo ahí en el contexto, entonces el trabajo del modelo cambia de "recuerda esto" a "lee lo que tienes delante y responde basándote en ello". La recuperación mueve el trabajo de la memoria a la lectura. La imagen clara es un examen: un examen de libro cerrado invita a adivinar, mientras que RAG lo convierte en un examen de libro abierto donde la página relevante está abierta en el escritorio.

JunoEl patrón recuperar-luego-generar RAG añade dos pasos a una llamada normal al modelo: recupera los documentos más relevantes para la pregunta, luego genera una respuesta usándolos como contexto. El modelo suministra el lenguaje, el texto que recuperaste suministra los hechos. Esto te permite responder desde tu contenido en lugar de desde la memoria, lo que es lo que evita que invente respuestas. Me tomó un tiempo confiar, pero un modelo de libro abierto realmente adivina menos que uno de libro cerrado.

Un modelo no lleva contigo los documentos de ayuda, los detalles de producto, o nada después de su corte de entrenamiento. Pregunta de todas formas y obtienes un rechazo o una respuesta inventada que se ve bien. La generación aumentada por recuperación cierra esa brecha recuperando texto relevante y colocándolo en la solicitud en tiempo de solicitud, para que el modelo lea el hecho en lugar de recordarlo. El mecanismo descansa en embeddings, donde cada fragmento de texto se convierte en un vector y la similitud es la distancia entre vectores (similitud del coseno, el coseno del ángulo entre ellos).

El ciclo es dos pasos:

  1. Recuperar. Incrusta la pregunta en el mismo espacio vectorial que tus documentos, luego extrae los fragmentos más cercanos por similitud.
  2. Generar. Concatena esos fragmentos en la solicitud como contexto, e instruye al modelo para que responda solo basándose en ellos.

El paso de recuperación tiene un marcador que estableces llamado top-k: cuántos de los fragmentos más cercanos realmente mantienes. k es un equilibrio. Demasiado pequeño y te pierdes el fragmento que contenía la respuesta, así que el modelo no tiene nada con lo que trabajar. Demasiado grande y rellenas el contexto con texto vagamente relacionado, que diluye la señal, aumenta el costo, y empeora el problema de perdido-en-el-medio. Un k de 3 a 5 es un lugar razonable para empezar, y lo ajustas contra preguntas reales.

Aquí está el movimiento: estructura el índice como una lista de registros, cada uno conteniendo el texto del fragmento y su vector precompilado, y estructura la solicitud de forma que el contexto recuperado se encuentre en un bloque claramente delimitado arriba de la pregunta. La capacidad de lenguaje del modelo escribe; el texto que recuperaste decide qué es verdad.

python
def retrieve(question, index, k=4):
    query_vector = embed(question)
    scored = [
        {"text": item["text"], "score": cosine_similarity(query_vector, item["vector"])}
        for item in index
    ]
    return sorted(scored, key=lambda x: x["score"], reverse=True)[:k]
JunoEl patrón recuperar-luego-generar RAG es recuperar luego generar: incrusta la pregunta, extrae los fragmentos más cercanos, suéltalo en la solicitud, y responde basándote en ellos. El marcador que importa aquí es top-k, el número de fragmentos que mantienes: muy pocos y te pierdes la respuesta, demasiados y la entierras en ruido y pagas por el privilegio. Comienza alrededor de 3 a 5 y ajusta contra preguntas reales, no intuiciones.

Un modelo no contiene tu corpus privado o nada después de su corte, y no te dirá cuál es; responde en el mismo tono seguro de cualquier forma. La generación aumentada por recuperación es el patrón de recuperar texto relevante en tiempo de solicitud y fundamentar la respuesta en él, por lo que la corrección descansa en una fuente que controlas en lugar de en recuerdo paramétrico. Se ejecuta en embeddings: texto mapeado a vectores, clasificado por similitud del coseno (el coseno del ángulo entre dos vectores, así que cercanía de significado independientemente de la longitud).

El encuadre de dos pasos, recuperar luego generar, es correcto pero oculta dónde está el trabajo. La recuperación es un problema de búsqueda con gorro de generación, y la calidad de todo el sistema se establece ahí, no en la llamada del modelo. Así que trata recuperar-luego-generar como recuperar-clasificar-luego-generar desde el principio, porque la versión ingenua, incrusta la consulta, toma el k más cercano, pégalo adentro, se quiebra en los modos de fallo que los niveles inferiores aún no han encontrado: fallos de coincidencia exacta, fragmentos casi duplicados amontonándose y desplazando cobertura, y texto obsoleto respondiendo con confianza.

El primer hábito que vale la pena implementar pronto es que este es un sistema medible, no una intuición. Reserva un conjunto de preguntas reales con los fragmentos que deberían responderlas, luego rastrea recall@k: de los fragmentos que deberían haber sido recuperados, la fracción que aterrizó en tu top k. Recall@k es tu techo. Si el fragmento correcto no está en contexto, ninguna cantidad de diseño de solicitud lo recupera, y el paso generar no puede responder lo que la recuperación nunca le entregó. Todo lo posterior está acotado por este número, así que es lo primero a medir y lo primero a mejorar.

python
def retrieve(question, index, k=8):
    query_vector = embed(question)
    scored = [
        {"text": it["text"], "score": cosine_similarity(query_vector, it["vector"]), "meta": it["meta"]}
        for it in index
    ]
    # sobre-busca aquí, luego re-clasifica hacia un conjunto final más pequeño (ver abajo)
    return sorted(scored, key=lambda x: x["score"], reverse=True)[:k]
JunoEl patrón recuperar-luego-generar RAG es un problema de búsqueda con gorro de generación: la calidad se establece en la recuperación, no en la llamada del modelo, así que la versión ingenua de incrusta-toma-k-pega se desmorará en coincidencias exactas y texto obsoleto. El número que limita todo es recall@k, la fracción de los fragmentos que deberían haber sido recuperados que realmente llegaron a tu top k. Si el fragmento correcto nunca llega al contexto, ninguna solicitud te salva. Mide eso antes de ajustar cualquier otra cosa, o estás puliendo el extremo equivocado.

Chunking

No incrustas documentos completos. Los divides en fragmentos más pequeños primero, un párrafo o pocas oraciones cada uno, e incrustas esos. Dos razones. Primero, la recuperación es más precisa: extraes el párrafo relevante en lugar de un manual completo de 40 páginas. Segundo, los fragmentos encajan en la ventana de contexto, donde un documento gigante podría no hacerlo.

python
# un fragmentador pequeño: divide en párrafos
def chunk(text):
    return [c.strip() for c in text.split("\n\n") if c.strip()]

El tamaño del fragmento es un equilibrio, y la razón se remonta a embeddings. Cada fragmento se convierte en un vector único que resume su significado completo. Haz un fragmento demasiado grande y cubre varios temas, así que su vector único es un promedio borroso de todos ellos que no coincide bien con ninguna pregunta específica. Hazlo demasiado pequeño y pierdes el significado circundante que necesitaba para tener sentido, así que el vector apunta a un fragmento. Los fragmentos de tamaño de párrafo cada uno contienen una idea coherente. Ajustas desde ahí basándote en cómo funciona tu recuperación.

JunoChunking Antes de incrustar, divide los documentos en fragmentos más pequeños, de tamaño aproximado de párrafo, e incrusta esos. Los fragmentos más pequeños hacen la recuperación más precisa y encajan mejor en la ventana de contexto. El tamaño del fragmento es un equilibrio: demasiado grande es borroso e ineficiente, demasiado pequeño pierde el significado circundante. Cuando mis respuestas volvían vagas, los fragmentos casi siempre eran demasiado grandes.

Incrustas fragmentos, no documentos completos, porque cada fragmento se convierte en un vector único que tiene que resumir su significado completo. Divide un documento en fragmentos de tamaño de párrafo e incrusta cada uno. Un fragmento demasiado grande promedia varios temas en un vector que no coincide con nada de forma aguda; un fragmento demasiado pequeño pierde el contexto que lo hacía significativo.

Así que trata el tamaño del fragmento como un marcador de ajuste, no una regla fija. Un fragmento de pocas centenas de tokens tiende a contener una idea limpiamente. El marcador interactúa con top-k: fragmentos más grandes significan que menos de ellos caben en tu presupuesto de contexto, así que llevas menos cobertura por solicitud. Fragmentos más pequeños aumentan la precisión pero necesitan un k más grande para rearmar una idea que los abarca.

El segundo marcador es overlap: deja que fragmentos consecutivos compartan un fragmento de texto en sus límites, digamos 10 a 20 por ciento. Sin overlap, una oración que responde la pregunta puede aterrizar dividida en un límite, mitad en un fragmento y mitad en el siguiente, así que ningún fragmento se recupera bien. El overlap te cuesta algo de duplicación y algunos vectores más, y te compra las respuestas que los límites de otra forma cortarían por la mitad.

python
def chunk(text, size=800, overlap=120):
    # size y overlap en caracteres aquí; presupuesta en tokens para sistemas reales
    out, start = [], 0
    while start < len(text):
        out.append(text[start:start + size])
        start += size - overlap
    return out
JunoChunking El tamaño del fragmento y el overlap son marcadores de ajuste, no configuraciones que aciertes una sola vez. Apunta a fragmentos que contengan una idea, pocas centenas de tokens, y recuerda que el marcador canjea contra top-k: fragmentos más grandes, menos caben. Añade 10 a 20 por ciento de overlap para que una respuesta que se atraviesa un límite todavía viva completa dentro de al menos un fragmento. Los límites son donde la recuperación silenciosamente pierde respuestas.

El chunking es donde se gana o se pierde el recall de recuperación antes de que una sola consulta se ejecute. Cada fragmento se convierte en un vector único que tiene que representar su significado completo, así que la división decide qué es encontrable. Los límites de párrafo o sección vencen las ventanas de caracteres fijas cuando el documento tiene estructura, porque un vector sobre una sección coherente es un objetivo de consulta más limpio que un vector sobre la mitad de dos secciones.

El tamaño del fragmento y el overlap están acoplados a tu presupuesto completo, no son parámetros libres. El tamaño canjea contra top-k contra la ventana de contexto: fragmentos grandes llevan más contexto cada uno pero menos caben, así que cae cobertura; fragmentos pequeños aumentan la precisión pero fragmentan ideas y necesitan un k más alto para rearmar, que cuesta tokens y reintroduce perdido-en-el-medio. El overlap de aproximadamente 10 a 20 por ciento rescata respuestas que se atraviesan un límite, al costo de duplicación en tu índice. La duplicación tiene una mordida posterior: fragmentos superpuestos casi idénticos pueden llenar varios de tus slots top-k con el mismo contenido, privando de cobertura. Esa es una de las razones por las que la re-clasificación y la deduplicación ganan su lugar después.

El costo que la mayoría salta hasta que duele: la estrategia de chunking se hornea en tu índice, así que cambiarla significa re-incrustar e re-indexar el corpus completo. Eso es cómputo real, dinero real, y una ventana de migración donde vectores viejos y nuevos no deberían mezclarse. Decide el tamaño del fragmento contra tus documentos y consultas reales temprano, mide recall@k en un conjunto reservado, y trata un re-chunk como una re-indexación deliberada, no un ajuste de configuración que despliegas un viernes.

JunoChunking Haz chunking en la estructura donde la tienes, porque cada fragmento es un vector que representa su significado completo, y el tamaño canjea contra top-k contra la ventana todo a la vez. El overlap de 10 a 20 por ciento salva respuestas que se atraviesan un límite pero genera casi-duplicados que abarrotan tu top-k, que es parte de por qué existe la re-clasificación. Y recuerda que la opción de chunking se hornea en el índice: cambiarla es una re-incrustación y re-indexación completa, costo real y una migración, no un cambio de configuración.

Dónde viven los vectores

Para un puñado de fragmentos, puedes mantener los vectores en memoria y compararlos con la función de similitud del coseno del capítulo embeddings, que es lo que el ejemplo posterior hace. Para miles o millones de fragmentos, eso se vuelve lento, y recurres a un almacén de vectores: una base de datos construida para encontrar los vectores más cercanos rápidamente a escala.

No necesitas uno para aprender RAG, y no necesitas elegir uno ahora. Varios existen como servicios alojados y bibliotecas; el concepto es lo que importa: un lugar para almacenar incrustaciones y buscarlas rápidamente. Comienza en memoria, pasa a un almacén cuando la colección la sobrepase.

JunoDónde viven los vectores Para unos pocos fragmentos, mantén vectores en memoria y compáralos directamente. Para colecciones grandes, un almacén de vectores es una base de datos que encuentra vectores más cercanos rápidamente a escala. No necesitas uno para aprender RAG: comienza en memoria y pasa a un almacén de vectores cuando la colección la sobrepase. No hay necesidad de comprar una base de datos el primer día.

Para unos pocos cientos de fragmentos, los vectores viven en memoria y calificas cada uno contra la consulta. Eso es un escaneo lineal: bien a pequeña escala, lento una vez que la colección llega a miles o millones, porque comparas contra cada vector en cada consulta. En ese punto te mueves a un almacén de vectores, una base de datos que indexa vectores para búsqueda rápida de vecinos más cercanos.

La asimetría que vale la pena mantener es incrusta-una-vez versus incrusta-por-consulta. Incrustas e indexas cada fragmento de documento exactamente una vez, por adelantado, y ese trabajo se reutiliza para cada pregunta futura. Incrustas la consulta una vez por solicitud, en tiempo de solicitud, y ese es el único costo de incrustación en el camino caliente. Así que el costo de indexación pesada se paga por adelantado y se amortiza; el costo por consulta es una llamada de incrustación más una búsqueda. Eso es lo que hace que RAG sea barato de servir una vez que está construido.

No necesitas elegir un almacén para aprender el patrón. Comienza con una lista de registros en memoria, cambia a un almacén de vectores cuando la escala o persistencia lo requiera, y mantén la interfaz igual: incrusta consulta, busca, devuelve top-k.

JunoDónde viven los vectores En memoria escaneas cada vector por consulta, que está bien hasta que la colección crece, luego un almacén de vectores te da búsqueda indexada de vecinos más cercanos. Mantén la asimetría en tu cabeza: los documentos se incrustan e indexan una sola vez y se reutilizan por siempre, mientras que la consulta se incrusta fresca cada solicitud. El costo inicial se paga una sola vez y se amortiza, así que servir se mantiene barato. Comienza en memoria, cambia a un almacén cuando la escala o persistencia lo obligue.

En memoria haces un escaneo lineal, calificando la consulta contra cada vector de fragmento, que es exacto pero O(n) por consulta y deja de escalar en algún lugar en los decenas de miles. Un almacén de vectores reemplaza el escaneo con un índice de vecinos más cercanos aproximado (ANN): canjea un poco de recall por una gran aceleración al no revisar cada vector. Ese canje es un marcador, y a escala de producción aceptas que el índice ocasionalmente puede perder un vecino verdaderamente más cercano, que se pliega directo en tu recall@k.

La asimetría de incrusta-una-vez establece tu forma de costo. Indexar incrusta el corpus completo una sola vez, un costo de lote grande por adelantado; servir incrusta una consulta y ejecuta una búsqueda por solicitud. Así que tu presupuesto de latencia de estado estable es aproximadamente: incrusta la consulta, busca el índice, luego la llamada generar. La búsqueda es usualmente la rebanada más pequeña; la llamada generar domina la latencia, y la incrustación de consulta es un impuesto fijo que pagas antes de poder recuperar en absoluto. Vale la pena medir cada parte, porque la gente recurre a un almacén de vectores más rápido cuando la latencia real estaba en generación todo el tiempo.

Luego está la antigüedad, el modo de fallo que el juguete en memoria nunca te muestra. Tu índice es una instantánea; los documentos fuente divergen. Una política cambia, un documento se edita, un fragmento viejo aún está en el índice apuntando al pasado, y la recuperación lo sirve con plena confianza. Así que un índice no es escribir-una-vez: necesitas una ruta de re-indexación que re-incruste documentos cambiados, e idealmente metadatos en cada fragmento (id de fuente, versión, marca de tiempo) para que puedas invalidar o filtrar entradas obsoletas. Decide la cadencia de actualización contra qué tan rápido se mueve tu verdad, y trata "el índice es actual" como algo que verificas, no asumes.

JunoDónde viven los vectores Un almacén de vectores canjea el escaneo lineal exacto por un índice de vecinos más cercanos aproximado: más rápido, pero puede perder un verdadero vecino, que desemboca en tu recall@k. La incrustación es una sola vez para el corpus y una sola vez por consulta, así que la latencia de estado estable es incrusta-consulta más búsqueda más generar, y la llamada generar usualmente domina, así que mide antes de culpar al almacén. La trampa que el juguete nunca muestra es antigüedad: el índice es una instantánea, las fuentes divergen, así que construye una ruta de re-indexación y marca fragmentos con versión e marca de tiempo en lugar de confiar en que el índice es actual.

Fundamentación y citación

El paso generar es una solicitud, y cómo la escribes decide si RAG realmente reduce respuestas inventadas. Dos instrucciones hacen el trabajo pesado: dile al modelo que responda solo del contexto proporcionado, y dile que diga así cuando el contexto no contenga la respuesta.

python
context_text = "\n\n".join(retrieved_chunks)

system_prompt = f'''Responde la pregunta usando SOLO el contexto a continuación.
Si el contexto no contiene la respuesta, di "No tengo esa información."
Cita la parte relevante del contexto en tu respuesta.

Contexto:
"""
{context_text}
"""'''

Esto junta dos lecciones anteriores. Los delimitadores y el explícito "responde solo de esto" vienen de prompting. El permiso para decir "No tengo esa información" es la defensa de alucinación: un modelo fundamentado con una salida de emergencia inventa mucho menos que uno dejado para adivinar. Fundamentación más una salida de emergencia es lo que corta respuestas inventadas. Pedirle que cite la fuente también da a los usuarios algo para verificar.

JunoFundamentación y citación El paso generar es una solicitud, y decide si RAG funciona. Dile al modelo que responda solo del contexto proporcionado y que diga así cuando la respuesta no está ahí. Esa fundamentación, más permiso para admitir que no sabe, es lo que reduce respuestas inventadas. Pedirle que cite la fuente da a los usuarios algo para verificar, y te da una pista cuando la recuperación se perdió.

La recuperación puede darle al modelo contexto perfecto y el modelo aún así puede ignorarlo. La solicitud generar es lo que vincula la respuesta al contexto, así que escríbela deliberadamente: instruye al modelo para que responda solo del contexto proporcionado, para admitir cuando el contexto carece de la respuesta, y para citar el fragmento que usó.

python
context_text = "\n\n".join(f"[{i}] {c}" for i, c in enumerate(retrieved_chunks))

system_prompt = f'''Responde usando SOLO el contexto a continuación.
Si la respuesta no está en el contexto, responde exactamente: "No tengo esa información."
Cita los números de fragmento que usaste, como [2].

Contexto:
"""
{context_text}
"""'''

Numerar los fragmentos convierte "cita la fuente" en una citación que puedes verificar programáticamente: el modelo apunta al fragmento [2], y puedes confirmar que el fragmento [2] realmente apoya la afirmación. Ese es tu asidero en el modo de fallo más faltante en RAG, la respuesta correcta con los fragmentos incorrectos recuperados, donde el modelo produce una respuesta que suena correcta que el contexto suministrado no apoya. Sin citaciones no puedes distinguir una respuesta fundamentada de una adivinanza afortunada.

La salida de emergencia importa tanto como la instrucción. Un modelo instruido para responder solo del contexto pero sin permiso para fallar aún cosería algo de una recuperación pobre. Dale una salida explícita, la cadena exacta "No tengo esa información", y una falta de recuperación se presenta como una brecha limpia en lugar de una invención segura. La forma del mensaje aquí es estilo OpenAI; la disposición sistema-luego-contexto varía por proveedor, pero el principio se sostiene.

JunoFundamentación y citación La solicitud generar vincula la respuesta al contexto: responde solo de él, admite cuando no está ahí, y cita el fragmento. Numera los fragmentos para que la citación sea verificable, lo que atrapa el fallo astuto donde la respuesta es correcta pero los fragmentos recuperados nunca la apoyaron. Dale al modelo la cadena de salida exacta, o una recuperación débil se teje en una respuesta equivocada segura. La forma varía por proveedor, pero la estructura sistema-luego-contexto se traslada.

La fundamentación es el contrato entre recuperación y el modelo, y es el marcador de confiabilidad más barato que tienes, así que gasta la solicitud en ello. Instruye al modelo para que responda solo del contexto proporcionado, para devolver una cadena de abstención fija cuando el contexto no apoya una respuesta, y para citar los ids de fragmento que usó.

python
context_text = "\n\n".join(f"[{c['id']}] {c['text']}" for c in retrieved)

system_prompt = f'''Responde usando SOLO el contexto a continuación.
Si el contexto no apoya una respuesta, responde exactamente: "No tengo esa información."
Para cada afirmación, cita los ids de fragmento entre paréntesis, p. ej. [doc12].

Contexto:
"""
{context_text}
"""'''

Las citaciones no son decoración; son cómo haces la fundamentación medible. Con ids de fragmento en la salida puedes verificar, automáticamente, si un fragmento citado realmente contiene la afirmación, lo que separa una respuesta fundamentada de una adivinanza fluida. Así es también cómo detectas el fallo que derrota a RAG ingenuo: la respuesta es plausiblemente correcta pero el contexto recuperado nunca la apoyó, así que la recuperación se perdió y el modelo rellenó la brecha de la memoria paramétrica. Sin ids estás verificando a vista; con ellos puedes calificar fundamentación en tus evals (un conjunto de preguntas reservadas con respuestas conocidas-buenas, calificadas automáticamente) y observar fidelidad como un número.

Dos notas de producción. La ruta de abstención tiene que ser barata y autorizada, porque un modelo sin permiso para fallar siempre producirá algo, y una recuperación pobre entonces se convierte en una respuesta equivocada segura en lugar de una brecha limpia. Y la estructura del mensaje aquí es forma OpenAI-SDK; la división sistema-versus-usuario y cómo se adjunta contexto varía por proveedor, así que trata la disposición como portátil, los nombres de campo exactos como no. La regla durable debajo: nunca dejes que el modelo sea la fuente, solo el generador de frases de una fuente que puedes citar y verificar.

JunoFundamentación y citación La fundamentación es el contrato: responde solo del contexto, abstente con una cadena fija, y cita ids de fragmento para que fidelidad se convierte en algo que calificas en evals, no examinas a ojo. Las citaciones son cómo atrapas la respuesta que es correcta pero sin apoyo, donde la recuperación se perdió y el modelo rellenó de memoria. Mantén la ruta de abstención barata y autorizada o una recuperación débil se convierte en una respuesta equivocada segura. La forma varía por proveedor; la estructura se traslada, los nombres de campo no.

Más allá del vecino más cercano: búsqueda híbrida y re-clasificación

La búsqueda de vectores puros coincide en significado, que es lo que quieres la mayoría del tiempo. Pero tiene un punto ciego: puede perder una palabra exacta que importa. Busca un código de error como E_4021 o un nombre de producto específico, y una búsqueda basada en significado puede devolver fragmentos que son sobre el mismo tema mientras se salta el que contiene la cadena exacta.

La solución tiene un nombre, búsqueda híbrida: combina búsqueda basada en significado con búsqueda de palabras clave antigua, para que términos exactos y significado general ambos obtengan voto. No necesitas construir esto para comenzar con RAG. Recurre a ello cuando términos exactos importan y los vectores puros sigan perdiéndolos.

JunoMás allá del vecino más cercano: búsqueda híbrida y re-clasificación La búsqueda de vectores coincide significado, que es genial hasta que alguien busca un código exacto o nombre de producto y el fragmento correcto se salta. La búsqueda híbrida mezcla búsqueda de palabras clave simple para que términos exactos cuenten también. Puedes comenzar RAG sin ello y añadirlo una vez que notes fallos de coincidencia exacta acumulándose.

La búsqueda de vecinos más cercanos por vector coincide significado, y esa es también su debilidad. Puede clasificar un fragmento topicalmente cercano sobre el fragmento exacto que contiene el token que necesitas, un código de error, un SKU, un nombre de función, un sustantivo propio raro. Las incrustaciones esparcen eso en "aproximadamente esta área de significado", y las cadenas exactas se pierden en el esparcimiento.

Búsqueda híbrida ejecuta dos recuperadores y los fusiona: la búsqueda de vectores para significado, más una búsqueda de palabras clave (coincidencia de términos clásica, a menudo BM25, que califica documentos por cuántas de las palabras exactas de la consulta contienen y qué tan raras son esas palabras). Tomas candidatos de ambos y combinas las clasificaciones, así que un fragmento que gana en términos exactos o cercanía semántica puede emerger. El pago práctico es recall en exactamente las consultas que vectores puros tropiezan.

La segunda técnica es re-clasificación: una segunda pasada que re-califica tus candidatos de primera pasada con un modelo más exacto, más caro. El patrón es sobre-busca entonces reduce. Recupera un conjunto generoso barato, digamos el top 20, luego ejecuta un re-clasificador que lee cada candidato contra la consulta y los reordena, y mantén el top 4 para realmente enviar. La recuperación de primera pasada es rápida y áspera; el re-clasificador es lento y agudo, así que solo lo ejecutas en la lista corta. El resultado es un mejor k final sin pagar el costo del re-clasificador en tu índice completo.

JunoMás allá del vecino más cercano: búsqueda híbrida y re-clasificación La búsqueda pura de vectores pierde cadenas exactas como códigos de error y SKUs porque las incrustaciones las borran en una región de significado. La búsqueda híbrida añade coincidencia de palabras clave (BM25) para que términos exactos obtengan voto junto con semántica. La re-clasificación es un patrón barato-luego-agudo: sobre-busca un conjunto amplio con recuperación rápida, luego re-califica la lista corta con un modelo más lento y exacto y mantén los mejores pocos. Obtienes mejores resultados finales sin ejecutar el calificador caro en todo.

El vecino más cercano aproximado puro es un fuerte predeterminado y una superficie de fallo conocida. Optimiza para proximidad semántica, así que funciona mal exactamente donde la consulta depende de un token exacto, un código de error, un identificador, un SKU, un sustantivo propio raro, porque la incrustación comprime ese token en un vecindario de significado donde las cadenas exactas se borran juntas. Eso no es un problema de ajuste; es lo que hacen los vectores densos.

Búsqueda híbrida lo aborda ejecutando recuperación densa (vectores, significado) junto con recuperación dispersa (coincidencia de términos de palabras clave, típicamente BM25, que califica en frecuencia de término exacto pesada por rareza de término) y fusionando las listas de resultado, comúnmente con fusión de rango recíproco, que combina dos clasificaciones por posición de cada elemento en lugar de por puntuaciones brutas incomparables. La ganancia es medible como recall@k en consultas de coincidencia exacta, que es la rebanada que puro denso silenciosamente bota. El costo es un segundo índice y un paso de fusión, así que paguel donde tu tráfico incluye identificadores y términos raros, y menos donde todo es parafraseo en lenguaje natural.

Re-clasificación ataca una falta diferente. La recuperación de primera pasada (densa, dispersa, o híbrida) se construye para velocidad en un índice grande, así que clasifica groseramente. Un re-clasificador es un codificador cruzado (un modelo que lee la consulta y un fragmento candidato juntos y califica su relevancia conjuntamente, en lugar de comparar dos vectores precomputados) que es más exacto y demasiado lento para ejecutar en el corpus completo. Así que sobre-buscas con el recuperador barato, top 20 a 50, luego re-clasificas esa lista corta y mantienes los últimos pocos.

Esta es la solución de recall con el pago más grande en la mayoría de RAG de producción: directamente ataca el fallo respuesta-correcta-fragmentos-incorrectos promoviendo el fragmento que realmente apoya la respuesta sobre los meramente temáticos, y mides la ganancia como recall@k antes versus después de la etapa de re-clasificación. Presupuestalo: el re-clasificador añade latencia en el camino caliente y costo por consulta, así que dimensiona la sobre-búsqueda contra tu objetivo de latencia, no contra cuántos candidatos puedes permitirte calificar en abstracto.

JunoMás allá del vecino más cercano: búsqueda híbrida y re-clasificación Los vectores densos borran tokens exactos, así que la búsqueda híbrida ejecuta recuperación dispersa de palabras clave (BM25) junto a densa y fusiona las listas con fusión de rango recíproco, recuperando recall@k en identificadores y términos raros. La re-clasificación es la ganancia más grande: sobre-busca barato, luego re-califica la lista corta con un codificador cruzado que lee consulta y fragmento juntos, y directamente arregla respuesta-correcta-fragmentos-incorrectos promoviendo el fragmento que apoya la respuesta. Mide recall@k antes y después, y presupuesta la latencia y costo por consulta del re-clasificador contra tu objetivo de camino caliente.

En práctica

Una respuesta RAG mínima, reutilizando los ayudantes search y embed del capítulo embeddings:

python
def answer_from_docs(question, index):
    # 1. recuperar: los fragmentos que mejor coinciden para la pregunta
    query_vector = embed(question)
    scored = [{"text": item["text"], "score": cosine_similarity(query_vector, item["vector"])} for item in index]
    top = sorted(scored, key=lambda x: x["score"], reverse=True)[:3]

    # 2. generar: responde usando solo esos fragmentos
    context = "\n\n".join(t["text"] for t in top)
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": f'Responde usando SOLO este contexto. Si no está aquí, di que no sabes.\n\n"""{context}"""'},
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content

Recupera los tres fragmentos más cercanos, suéltalo en la solicitud, y deja que el modelo responda desde ellos. Ese es el pipeline RAG completo: la búsqueda embeddings alimentando una llamada del modelo, con una solicitud fundamentada sosteniéndolo todo. Casi toda la calidad vive en recuperar los fragmentos correctos.

JunoEn práctica Una respuesta RAG mínima es búsqueda de incrustaciones alimentando una llamada del modelo: recupera los fragmentos más cercanos, suéltalo en la solicitud, y pide al modelo que responda solo desde ellos. La recuperación suministra los hechos y la solicitud fundamentada mantiene la respuesta anclada a ellos. Casi toda la calidad vive en recuperar los fragmentos correctos, así que ese es donde gastar tu tiempo.

Una llamada RAG que funciona es recuperar, fundamentar, generar, con el asidero de citación de antes cableado adentro:

python
def answer_from_docs(question, index, k=4):
    top = retrieve(question, index, k)  # del patrón arriba
    context = "\n\n".join(f"[{i}] {t['text']}" for i, t in enumerate(top))
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content":
                f'Responde usando SOLO este contexto. Si no está aquí, responde '
                f'"No tengo esa información." Cita fragmentos como [1].\n\n"""{context}"""'},
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content

Todo es la búsqueda embeddings alimentando una llamada del modelo, sostenida por una solicitud fundamentada. La forma chat.completions es estilo OpenAI; los roles de mensaje y firma de llamada varían por proveedor, pero el flujo recuperar-luego-fundamentar-luego-generar es el mismo en todas partes.

Cuando una respuesta es incorrecta, depura en orden de etapa. Primero verifica los fragmentos recuperados: ¿el texto relevante aterrizó en top? Si no, la solución está en recuperación, sube k, arregla chunking, añade híbrida o re-clasificación, no en la solicitud. Si el fragmento correcto fue recuperado y la respuesta sigue siendo incorrecta, entonces es un problema de solicitud. Este orden te salva de ajustar el diseño de solicitud contra un bug de recuperación, que nunca converge.

JunoEn práctica Una llamada RAG que funciona es recuperar, fundamentar con citaciones, generar, y la forma estilo OpenAI se traslada a otros proveedores en estructura si no en nombres de campo. Depura en orden fijo: mira los fragmentos recuperados primero. Si el texto correcto nunca llegó, arregla recuperación (k, chunking, híbrida, re-clasificación), no la solicitud. Solo una vez que el fragmento correcto está en contexto es una respuesta incorrecta un problema de solicitud.

En práctica el pipeline recuperar-clasificar-generar es una función, y las partes que instrumentas son las uniones entre etapas:

python
def answer_from_docs(question, index, k_fetch=20, k_final=4):
    candidates = retrieve(question, index, k=k_fetch)   # densa (o híbrida)
    top = rerank(question, candidates)[:k_final]        # pasada de codificador cruzado
    context = "\n\n".join(f"[{c['id']}] {c['text']}" for c in top)
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content":
                f'Responde usando SOLO este contexto. Si no es compatible, responde '
                f'"No tengo esa información." Cita ids como [doc12].\n\n"""{context}"""'},
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content, [c["id"] for c in top]

Devolver los ids de fragmento junto con la respuesta es el movimiento que hace el sistema observable: registras qué fue recuperado, qué fue citado, y si los ids citados realmente apoyan la respuesta, que es tu señal de fidelidad en evals. La forma chat.completions es OpenAI-SDK; trata el flujo como portable y los nombres de campo como específicos del proveedor.

El mapa de latencia y costo de este pipeline vale la pena mantener en tu cabeza, porque la optimización va donde está el tiempo:

  • Incrustación de consulta es un impuesto pequeño fijo.
  • Recuperación de primera pasada es barata.
  • Re-clasificación añade un golpe real por consulta escalado por tu tamaño de sobre-búsqueda.
  • La llamada generar domina tanto latencia como costo en dólares.

Así que antes de perseguir un almacén de vectores más rápido, confirma que la búsqueda fue alguna vez el cuello de botella; usualmente fue generación.

Y depura fallos por etapa en orden: conjunto recuperado, luego conjunto re-clasificado, luego citaciones, luego solicitud. Una respuesta incorrecta cuyo fragmento correcto nunca entró en candidates es un bug de recuperación, y ningún cambio de solicitud lo arregla.

JunoEn práctica El pipeline es recuperar, re-clasificar, fundamentar, generar, y devolver ids de fragmento junto con la respuesta es lo que lo hace observable: registra recuperado versus citado y puedes calificar fidelidad en evals en lugar de adivinar. Observa el mapa de costo, generación domina latencia y dólares, así que confirma que búsqueda fue el cuello de botella antes de cambiar almacenes de vectores. Depura por etapa en orden, recuperado luego re-clasificado luego citado luego solicitud, porque ninguna solicitud arregla un fragmento que nunca entró al conjunto de candidatos. La forma es OpenAI-SDK; el flujo se traslada, los nombres de campo no.

Cuándo no necesitas RAG

RAG no es gratis, y no siempre es la respuesta. Omítelo cuando:

  • Los datos son pequeños y caben en la solicitud. Si tu base de conocimiento completa es unos pocos párrafos, pégalos directamente. RAG es para cuando hay demasiado para enviar todo a la vez.
  • Un modelo de contexto largo puede sostenerlo todo. Algunos modelos aceptan entradas muy grandes, lo suficiente para tomar un documento completo a la vez, que puede ser menos trabajo que construir recuperación.

Y recuerda el límite real: La calidad de RAG es principalmente calidad de recuperación. Si el paso de búsqueda devuelve los fragmentos equivocados, incluso una solicitud perfecta no puede salvar la respuesta. La mayoría del esfuerzo en sistemas RAG reales va en recuperar el texto correcto, no en la llamada del modelo final.

Las incrustaciones y RAG dan al modelo conocimiento en el momento de la pregunta, sin nunca cambiar el modelo a sí mismo. El próximo capítulo, Fine-tuning, cubre la otra opción: cambiar el modelo con tus propios ejemplos, y cuándo eso vale la pena sobre prompting o RAG.

JunoCuándo no necesitas RAG Omite RAG cuando tus datos son lo suficientemente pequeños para pegar en la solicitud, o cuando un modelo de contexto largo puede sostenerlo todo a la vez. Y mantén el límite real en mente: la calidad de RAG es principalmente calidad de recuperación, así que si la búsqueda devuelve los fragmentos equivocados, ninguna solicitud puede rescatar la respuesta. La mayoría del trabajo real está en recuperar el texto correcto, no en la llamada final.

RAG es infraestructura, un índice, un paso de incrustación, un paso de recuperación, una ruta de re-indexación, y gana ese sobrecosto solo cuando los datos son demasiado grandes para enviar y cambian lo suficientemente a menudo que reentrenamiento no es una opción. Omítelo cuando una forma más barata hace el trabajo:

  • El corpus cabe en la solicitud. Unos pocos párrafos o un documento pequeño de política van directamente en contexto. RAG solo gana su sustancia cuando hay más de lo que puedes enviar a la vez.
  • Un modelo de contexto largo sostiene toda la fuente. Si el documento cabe una ventana grande, enviarla completa puede vencer un pipeline de recuperación que tienes que construir y ajustar, aunque pagas esos tokens en cada llamada y aún golpeas perdido-en-el-medio.

Mantén el hecho que rige: la calidad de RAG está acotada por calidad de recuperación. Una solicitud perfecta sobre fragmentos incorrectos aún responde mal, así que el índice y el paso de recuperación son donde está el trabajo y las ganancias, no la llamada del modelo final. Cuando necesitas cambiar comportamiento en lugar de suministrar hechos, Fine-tuning es el próximo capítulo, y resuelve un problema diferente que RAG.

JunoCuándo no necesitas RAG RAG es infraestructura con sobrecosto real, así que omítelo cuando el corpus cabe en la solicitud o un modelo de contexto largo puede tragar la fuente completa, recordando que la ruta de contexto largo paga tokens cada llamada y aún diverge en el medio. El hecho que rige se sostiene: la calidad está acotada por recuperación, así que una solicitud perfecta sobre fragmentos incorrectos aún responde mal. Gasta en el índice y recuperación, no la llamada final.

RAG te compra divisa y fundamentación al precio de un sistema de recuperación que construyes, ajustas, vigilas, y re-indexas. Así que la decisión es una de costo-beneficio, no un predeterminado. Omítelo cuando una forma más ligera despeja la barra:

  • Corpus pequeño, estable. Si cabe en la solicitud y raramente cambia, ponlo en contexto, y apóyate en almacenamiento en caché de solicitud para que el bloque fijo se sirva barato en llamadas repetidas. Un pipeline de recuperación aquí es sobrecosto sin pago.
  • Contexto largo, reutilización modesta. Si la fuente cabe una ventana grande, enviarla completa salta la construcción, pero pagas esos tokens de entrada en cada solicitud y recall aún se pudre en el medio, así que pierde contra RAG una vez que volumen o tamaño de documento asciende.

La restricción que rige es la misma que corre todo el capítulo: la calidad end-a-end está acotada por recall@k. Ninguna solicitud, ninguna mejora de modelo, ninguna re-clasificación recupera un hecho que recuperación nunca puso en contexto. Así que la inversión de ingeniería va en recuperación, chunking, híbrida, re-clasificación, evals, re-indexación, y la llamada del modelo es la parte barata de acertar. Y mantén RAG y fine-tuning en sus carriles separados: recuperación suministra qué es verdad y se mantiene actual conforme las fuentes cambian, mientras que fine-tuning da forma cómo responde el modelo y es una forma anticuada, sin fuente de inyectar hechos. Usa recuperación para conocimiento, fine-tuning para forma, y no pidas a ninguno que haga el trabajo del otro.

JunoCuándo no necesitas RAG RAG te compra divisa y fundamentación por el precio de un sistema que construyes y re-indexas, así que omítelo cuando el corpus es pequeño y estable (pégalo, apóyate en almacenamiento en caché de solicitud) o cabe una ventana larga con reutilización modesta. La restricción que corre todo es recall@k: nada posterior recupera un hecho que recuperación nunca superficializó, así que invierte en recuperación, no la llamada del modelo. Mantén los carriles limpios: recuperación para qué es verdad, fine-tuning para cómo suena, y nunca pidas a uno que haga el trabajo del otro.