← Volver a los relatos
· 16 min de lectura

RAG como memoria operativa: ingeniería de contexto para software

El proceso de ingeniería detrás de un sistema RAG para software, contado problema por problema: retrieval, chunking, metadata, tools, citas y seguridad.

iaragarquitectura

RAG se suele explicar como una técnica para “conectar un LLM a tus documentos”. Esa definición sirve para arrancar, pero se rompe en cuanto intentas llevarla a un sistema real.

Lo que cuento aquí no es la teoría, sino el proceso: cómo fui encontrando los problemas uno a uno mientras construía sistemas RAG para software —código fuente, documentación técnica, glosarios, historial de cambios, configuraciones, tickets y conocimiento operativo— y qué decisión resolvió cada uno. No puedo nombrar proyectos por confidencialidad, pero sí puedo compartir el recorrido técnico y los errores que evitaría si empezara de nuevo.

La conclusión a la que llegué se resume en una frase:

El encargo: dar contexto sobre software que no para de cambiar

El primer problema apareció antes de escribir una sola línea de código. El conocimiento que importaba cambiaba todo el tiempo:

  • commits nuevos cada día;
  • documentación que envejecía mal;
  • convenciones que solo viven en el código;
  • configuraciones distintas por ambiente;
  • módulos que entiende una sola parte del equipo;
  • estado de ejecución que depende del momento exacto de la consulta.

Entrenar o afinar un modelo para cada cambio no tenía sentido. La idea que me ordenó el diseño viene del paper original de RAG: combinar la memoria paramétrica del modelo —su capacidad lingüística y de razonamiento— con una memoria externa no paramétrica, consultada mediante retrieval, que aporta el conocimiento actualizado, específico y trazable.

Esa separación fue la primera decisión de arquitectura: no le pido al modelo que recuerde lo que cambia, se lo entrego fresco en cada consulta. Pero recuperar bien resultó mucho más difícil de lo que esperaba.

El primer cuello de botella: el modelo solo es tan bueno como lo que recupera

Retrieval es la etapa que decide qué fragmentos de conocimiento externo se le entregan al modelo antes de generar la respuesta. El flujo básico es directo:

  1. El usuario hace una pregunta.
  2. El sistema la convierte en una representación buscable.
  3. Busca en un índice los documentos o chunks más relevantes.
  4. Selecciona un conjunto pequeño de resultados, el top K.
  5. Inserta esos resultados en el prompt.
  6. El modelo responde usando la pregunta y el contexto recuperado.

En búsqueda vectorial, tanto la pregunta como los chunks se convierten en embeddings: vectores que representan significado aproximado. El retriever compara la similitud entre el vector de la pregunta y los del índice; si dos vectores están cerca, se asume que hablan de lo mismo.

dimensión 1 dimensión 2 top K pregunta billing/client.ts docs/integrations.md changelog/2024-q1 glossary/payments.md pregunta chunks top K resto del corpus
Proyección 2D del espacio de embeddings (el real tiene cientos de dimensiones): cada chunk es un vector, y el retriever devuelve los K más cercanos al vector de la pregunta.

El flujo completo, de la pregunta al prompt, se ve así:

pregunta: "por qué falla esta integración"
        -> embedding de la pregunta
        -> búsqueda en índice vectorial
        -> top 5 chunks: contratos de API, config y errores
        -> esos chunks entran al prompt
        -> el LLM redacta una explicación

El retriever no entiende ni redacta: su único trabajo es reducir el espacio de búsqueda, de un corpus de decenas de miles de chunks a un top K pequeño —entre 5 y 12 en mis sistemas— que probablemente contiene la evidencia. Por eso es la pieza más delicada de todo el sistema.

La arquitectura que fui armando

A medida que resolvía problemas, el sistema se estabilizó en un patrón con responsabilidades separadas: loaders que normalizan fuentes heterogéneas, chunking que las parte en unidades recuperables, metadata que añade estructura, un retriever híbrido, tools para lo que está vivo, y un prompt builder que arma el contexto con reglas y citas.

INDEXACIÓNCONSULTA Fuentes Loaders Chunking Metadata Índice Pregunta Retrieval Prompt LLM Tools Respuesta
El centro de la arquitectura no es el LLM, sino el contexto que se le construye.

La lección de fondo es que el LLM no está en el centro. El centro es el contexto. Si llega bien construido, el prompt puede ser simple; si llega mal, terminas intentando arreglar con instrucciones lo que en realidad era un problema de datos.

Cortar por tokens rompía el sentido: chunking por estructura

El chunking ingenuo corta cada N tokens con un overlap fijo. Para documentación narrativa alcanza; para software, no. Las primeras respuestas malas venían de chunks que partían una función a la mitad o mezclaban dos ideas sin relación.

Lo ataqué dejando que la estructura definiera la unidad. En código y sistemas técnicos, un chunk útil suele ser:

  • una clase o una función pública;
  • un módulo;
  • una sección de configuración;
  • un entry de glosario;
  • una sección de documentación;
  • un bloque de changelog;
  • un archivo pequeño que conviene mantener entero.

La regla que me quedó grabada:

El chunk debe parecerse a la unidad mental que usaría una persona para responder la pregunta.

Si alguien pregunta por una integración que falla, no quiere cinco pedazos arbitrarios de texto. Quiere saber qué componente interviene, desde qué capa, sobre qué contrato y con qué configuración. Por eso prefiero parsear cuando el corpus tiene estructura; y cuando no hay parser, al menos uso path, extensión, convenciones de carpetas y límites sintácticos simples. El embedding no debería tener que adivinar que services/, docs/decisions/ o config/ significan cosas distintas: eso ya lo sabe el sistema.

El índice entregaba ruido: metadata antes del vector

Uno de los saltos de calidad más grandes vino de dejar de tratar cada chunk como texto plano. Un chunk útil casi siempre necesita metadata:

{
  "source": "code",
  "file_path": "services/billing/client.ts",
  "kind": "service_client",
  "module": "billing",
  "symbol": "createInvoice",
  "source_layer": "application",
  "chunk_index": 0
}

Esa metadata me permitió filtrar antes de recuperar: “solo código propio”, “solo integraciones”, “solo fuentes citables”, “solo este módulo”, “solo esta versión del corpus”, “solo archivos permitidos por el rol del usuario”. Reduce ruido, mejora precisión y, de paso, mejora seguridad, porque los permisos se aplican antes de que el contenido llegue al prompt.

El impacto combinado de chunking por estructura, metadata y retrieval híbrido fue el salto más grande de todo el proyecto:

~4/10
evidencia correcta en top K
chunking por tokens, texto plano
~9/10
evidencia correcta en top K
estructura + metadata + híbrido

Números aproximados, medidos sobre mi set de pruebas interno de preguntas reales. Importa el orden de magnitud, no el decimal.

El corpus cambia todos los días: mantener el índice fresco

El problema que abrió el proyecto —software que no para de cambiar— volvió en forma de pregunta incómoda: ¿de qué sirve un retrieval excelente sobre un índice de la semana pasada? Re-indexar todo el corpus en cada cambio dejó de ser viable en cuanto pasó de unos pocos miles de chunks.

Lo que funcionó fue la ingesta incremental:

  • cada chunk guarda un hash de su contenido y de su fuente;
  • un cambio —commit, edición de documentación, configuración nueva— dispara la re-ingesta solo de los archivos afectados;
  • se re-chunkea y re-embeddea únicamente lo que cambió; el resto del índice no se toca;
  • los chunks huérfanos, cuyo archivo o sección ya no existe, se eliminan en el mismo paso.

Cada actualización incrementa la versión del corpus —la misma que después participa en la clave de cache. Y hay un costo oculto que conviene conocer antes de elegir modelo de embeddings: cambiarlo invalida el índice completo, porque los vectores de modelos distintos no son comparables. Re-embeddear todo el corpus es la migración más cara del sistema, y el versionado del índice también existe para hacerla sin downtime: el índice viejo sigue activo mientras se construye el nuevo.

Los símbolos exactos se perdían: retrieval híbrido

La búsqueda vectorial es buena para intención semántica: “¿dónde se calcula el precio final?” puede encontrar código que no contiene esas palabras. Pero falla con identificadores, nombres de métodos, rutas, clases, errores exactos, hashes y strings, y en software esos detalles importan muchísimo.

La solución fue combinar señales en lugar de elegir una:

Pregunta Normalizar Vectorial Léxico/ BM25 Filtros Rerank Prompt
Retrieval híbrido: intención por vectores, exactitud por léxico, control por metadata — y luego rerank.
  • vector search para la intención;
  • búsqueda exacta o BM25 para los símbolos;
  • filtros estructurales por metadata;
  • reranking cuando el top K inicial trae demasiado ruido;
  • inyección manual del contexto crítico cuando sé que no debe perderse.

Ese último punto importa: si el usuario pregunta desde una pantalla, ticket o archivo concreto, a veces conviene garantizar que el chunk de ese documento entre en el prompt aunque su score no sea el más alto. No es forzar el resultado; es reconocer que el sistema tiene señales que el embedding no ve.

Hay una señal más que hoy instalaría desde el principio: reescribir la consulta antes de buscar. Las preguntas reales llegan vagas —“por qué falla esto”, “no anda el checkout”— y apostar todo a un único embedding de esa frase es frágil. Generar dos o tres variantes (la pregunta reformulada en términos del dominio, los símbolos probables, el módulo sospechoso) y recuperar con todas mejora el recall sin tocar el índice. A esto llegué tarde: lo probé al final del recorrido, y sería de lo primero que montaría en el próximo sistema.

El índice no sabe qué pasa ahora: tools

Había preguntas que el índice nunca debería responder: “¿qué cambió en el último despliegue?”, “¿qué error aparece en los logs sanitizados?”, “¿quién tocó esta línea?”, “¿qué configuración está activa ahora?”. Eso no es conocimiento indexable, es estado vivo.

Lo resolví con tools muy concretas —y mientras más pequeñas y auditables, mejor:

get_recent_deploys     git_log
get_sanitized_logs     git_blame
get_related_tickets    git_show
search_commits         recent_activity

El reparto quedó claro: el índice responde “qué significa” o “dónde está documentado”; las tools responden “qué está pasando ahora”. Esa separación bajó mucho la alucinación, porque el modelo dejó de imaginar estado e historia: ahora los consulta.

Una optimización que me gustó fue ejecutar algunas tools antes de llamar al modelo. Si una pregunta de soporte casi siempre necesita estado del servicio, despliegues recientes y tickets relacionados, el backend los corre en paralelo y los mete en el prompt como contexto base. Eso elimina dos o tres rondas de tool calling —que en mi caso eran varios segundos cada una—, recorta la latencia percibida a menos de la mitad en las preguntas típicas de soporte y evita que el modelo olvide mirar lo obvio.

Respuestas que no se podían verificar: prompt-contrato y citas

Con buen contexto encima, el siguiente problema fue de confianza. El prompt del sistema no podía ser una lista de deseos vagos; tenía que funcionar como contrato operativo: responder en el idioma esperado, no inventar sin evidencia, preferir fuentes locales sobre teoría genérica, citar solo lo presente en el contexto, usar tools cuando la pregunta dependa de estado actual.

Lo que más me costó aprender fueron las reglas negativas, y todas nacieron de un error real:

Las citas fueron el otro frente. Pedir “incluye fuentes” no alcanza: el modelo inventa formatos, cita chunks que no usó o mezcla referencias. Lo resolví haciéndolas estructuradas, como un contrato entre retriever, prompt y UI:

  1. El retriever entrega cada chunk con una etiqueta citable controlada.
  2. El prompt solo permite esos formatos.
  3. La respuesta final se limpia antes de mostrarse.
  4. El backend reconstruye la lista de citas a partir de etiquetas válidas.
  5. Si no hay fuente, no hay cita.

En la práctica se ve así:

# cada chunk llega con una etiqueta citable controlada
[[src:billing/client.ts#createInvoice]]
  "El cliente de facturación valida el monto..."

# el modelo solo puede citar usando esas etiquetas
"...se valida antes de emitir
 [[src:billing/client.ts#createInvoice]]."

# el backend ignora lo que no sea una etiqueta conocida
# y reconstruye la cita como enlace al archivo o commit

Para conocimiento interno esto es clave: si alguien toma una decisión con base en una respuesta, necesita poder abrir el archivo, commit o documento que la respalda.

Lo que rompe en producción: cache, fallback y seguridad

Las decisiones anteriores hacían bueno al sistema en el papel. Llevarlo a producción reveló tres problemas más.

Cache. Cachear por el texto de la pregunta parecía obvio, hasta que la misma pregunta empezó a significar cosas distintas según la versión del corpus, el contexto activo, el estado operacional o los permisos del usuario.

Fallback. Los proveedores fallan, los modelos rate-limitan, el contexto se vuelve largo. El patrón que me salvó fue un modo degradado: si el LLM no responde, devolver los pasajes recuperados más relevantes con un mensaje claro. No es elegante, pero el usuario no queda bloqueado, las fuentes siguen disponibles y la observabilidad registra el error real. Eso diferencia un demo de una herramienta usable.

Seguridad. Indexar repos, documentación interna y tools de lectura crea otra forma de acceder al conocimiento de la organización. Las medidas que considero mínimas: whitelists de rutas; denylist para .env, secretos y logs crudos; separación por proyecto; control de acceso antes del retrieval; tools read-only por defecto; auditoría de preguntas, tools y errores; y revisión de qué fuentes son citables.

Cómo lo evalué

Preguntarle cosas al chat y ver si “suena bien” sirve para un demo, no para producción. Terminé probando en tres capas:

  1. Retrieval tests: dada una pregunta, el top K debe contener el chunk esperado.
  2. Answer tests: la respuesta debe mencionar puntos obligatorios y no incluir afirmaciones prohibidas.
  3. Tool tests: cada tool debe devolver JSON estable, acotado y sin datos fuera de scope.

Un retrieval test no necesita framework: es una tabla de casos y una aserción.

# retrieval-tests.yaml
- question: "¿dónde se valida el monto antes de facturar?"
  must_retrieve: "services/billing/client.ts#createInvoice"
  top_k: 8

- question: "¿qué timeout usa la integración de pagos en staging?"
  must_retrieve: "config/staging.yml#payments"
  must_not_retrieve: "config/production.yml"
  top_k: 8

El runner ejecuta cada pregunta contra el retriever y falla si el chunk esperado no aparece en el top K —o si aparece uno prohibido. Corre en CI en segundos, porque no llama al LLM: solo prueba la búsqueda. Este set es el que está detrás de los números de este post: empezó con unas veinte preguntas reales y creció con cada respuesta mala.

Y guardé cada respuesta mala como caso de regresión. En RAG muchas mejoras son invisibles hasta que algo se rompe; los tests evitan volver a romper lo que ya habías aprendido.

Lo que más me sirvió, y lo que haría distinto

Si ordeno el valor por retorno real, el ranking es claro:

  1. Metadata específica del dominio — mejora retrieval, seguridad y explicación a la vez.
  2. Chunking por estructura — evita que el índice sea texto sin forma.
  3. Tools pequeñas — resuelven estado vivo e historia sin inflar el corpus.
  4. Citas estrictas — hacen verificable la respuesta.
  5. Cache con versión de corpus — baja costo sin servir información vieja.
  6. Fallback degradado — convierte fallos del LLM en fallos tolerables.

Lo que menos retorno dio fue intentar resolver con prompt lo que eran problemas de datos. Si empezara otro sistema mañana, arrancaría definiendo primero las preguntas reales que debe contestar, diseñaría la metadata antes de elegir modelo, escribiría tests de retrieval desde la primera semana y mantendría el corpus pequeño hasta entender qué duele. El error caro no es indexar poco, es indexar mucho sin saber si sirve.

Checklist: RAG a producción

La versión accionable de todo lo anterior, en el orden en que lo revisaría:

  1. Define las preguntas reales que el sistema debe contestar antes de elegir stack.
  2. Diseña la metadata de los chunks antes de elegir modelo.
  3. Chunkea por estructura, no por tokens.
  4. Combina vector, léxico y filtros; no apuestes a una sola señal.
  5. Escribe tests de retrieval desde la primera semana y hazlos correr en CI.
  6. Resuelve el estado vivo con tools pequeñas y read-only, no indexándolo.
  7. Haz las citas estructuradas y verificables, no decorativas.
  8. Versiona el corpus, inclúyelo en la clave de cache y planifica la ingesta incremental.
  9. Aplica control de acceso antes del retrieval y trata el corpus como entrada no confiable.
  10. Mantén el corpus pequeño hasta que los tests te digan qué falta.

Cierre

RAG me sirvió de verdad cuando dejé de tratarlo como una integración de IA y empecé a tratarlo como una capa de ingeniería de contexto. El trabajo importante no está en llamar al modelo, sino en decidir qué entra al contexto, qué no, qué se consulta en vivo, qué se cita, qué se cachea, qué se bloquea por permisos y qué se responde con honestidad cuando no hay evidencia.

Esa es la idea con la que me quedo: un buen sistema RAG no reemplaza el criterio técnico. Lo codifica.

Dos frentes quedaron apenas esbozados aquí y merecen un post propio: cómo evaluar un RAG más allá del “suena bien”, y la seguridad de un RAG interno cuando el corpus es entrada no confiable. Son los siguientes de la lista.

Si estás construyendo algo así —o peleando con un RAG que suena bien pero no es confiable— es justo el tipo de problema en el que trabajo. Puedes escribirme.

Referencias