Lab práctico · Semana 3: Recuperación aumentada (RAG) a fondo

RAG avanzado - metadatos, reranking, descomposición y evaluación rápida

⏱ 90-120 minDificultad: avanzadaTask statements: 1.5

Qué vas a construir

Partiendo de la Knowledge Base del lab 05, vas a mejorar la recuperación con las técnicas del task statement 1.5 y a medir si mejora de verdad:

  1. Metadatos por documento (departamento, año) y filtros para evitar respuestas con políticas caducadas.
  2. Reranking con Amazon Rerank 1.0 (dos etapas: recuperar 10, quedarse con 3).
  3. Descomposición de consultas en RetrieveAndGenerate para preguntas compuestas.
  4. Plantilla de prompt personalizada con citas.
  5. Una evaluación rápida: conjunto de oro, hit rate, MRR y latencia de recuperación por configuración, más un LLM como juez para las respuestas.
flowchart LR
  Q["Pregunta"] --> F["Filtro de metadatos (anio, departamento)"]
  F --> R10["Recuperar top-10 (S3 Vectors)"]
  R10 --> RR["Amazon Rerank 1.0: top-3"]
  RR --> G["Nova Micro genera con plantilla propia"]
  G --> E["Evaluación: hit@k, MRR, latencia, LLM juez"]

Antes de empezar

  • Haber completado el lab 05 sin limpiar (Knowledge Base lab05-kb-cafes con S3 Vectors en eu-central-1).
  • Abre CloudShell en eu-central-1 y recupera las variables (por si la sesión se reinició):
export AWS_REGION=eu-central-1
export CUENTA=$(aws sts get-caller-identity --query Account --output text)
export KB_ID=$(aws bedrock-agent list-knowledge-bases --region $AWS_REGION \
  --query "knowledgeBaseSummaries[?name=='lab05-kb-cafes'].knowledgeBaseId" --output text)
export DS_ID=$(aws bedrock-agent list-data-sources --knowledge-base-id $KB_ID --region $AWS_REGION \
  --query "dataSourceSummaries[0].dataSourceId" --output text)
export BUCKET_DOCS=$(aws s3 ls | awk '{print $3}' | grep '^lab05-docs-' | head -n 1)
export VBUCKET=$(aws s3vectors list-vector-buckets --region $AWS_REGION \
  --query "vectorBuckets[?starts_with(vectorBucketName,'lab05-vectores-')].vectorBucketName | [0]" --output text)
export INDICE=kb-indice
export ROL_KB=lab05-kb-role
echo "KB=$KB_ID DS=$DS_ID BUCKET=$BUCKET_DOCS VBUCKET=$VBUCKET"
mkdir -p ~/lab06 && cd ~/lab06

Paso 1: añade una política caducada y metadatos

Un problema real de RAG: conviven documentos de años distintos. Añadimos la política de vacaciones de 2024 (con otros días) para provocar respuestas incorrectas.

cat > vacaciones-2024.md <<'EOF'
# Política de vacaciones de Cafés Montaña (2024, SUSTITUIDA)
Todos los empleados en España disfrutan de 22 días laborables de vacaciones al año.
En Portugal son 22 días laborables. Las solicitudes se enviaban por correo a RR. HH.
EOF
aws s3 cp vacaciones-2024.md s3://$BUCKET_DOCS/documentos/

Ahora crea un fichero .metadata.json por documento, con el mismo nombre del documento más .metadata.json y en la misma carpeta. Usamos includeForEmbedding: false porque solo queremos filtrar:

meta() {  # meta <fichero> <departamento> <anio>
cat > "$1.metadata.json" <<EOF
{
  "metadataAttributes": {
    "departamento": { "value": { "type": "STRING", "stringValue": "$2" }, "includeForEmbedding": false },
    "anio": { "value": { "type": "NUMBER", "numberValue": $3 }, "includeForEmbedding": false }
  }
}
EOF
}
meta vacaciones.md rrhh 2026
meta vacaciones-2024.md rrhh 2024
meta teletrabajo.md rrhh 2026
meta gastos.md finanzas 2026
meta seguridad.md ti 2026
aws s3 cp . s3://$BUCKET_DOCS/documentos/ --recursive --exclude "*" --include "*.metadata.json"
aws s3 ls s3://$BUCKET_DOCS/documentos/

Paso 2: vuelve a sincronizar (incremental)

La sincronización es incremental: detecta el documento nuevo y los metadatos añadidos.

JOB=$(aws bedrock-agent start-ingestion-job --knowledge-base-id $KB_ID --data-source-id $DS_ID \
  --region $AWS_REGION --query ingestionJob.ingestionJobId --output text)
while true; do
  E=$(aws bedrock-agent get-ingestion-job --knowledge-base-id $KB_ID --data-source-id $DS_ID \
    --ingestion-job-id $JOB --region $AWS_REGION --query ingestionJob.status --output text)
  echo "Estado: $E"; [ "$E" = "COMPLETE" ] || [ "$E" = "FAILED" ] && break; sleep 10
done
aws bedrock-agent get-ingestion-job --knowledge-base-id $KB_ID --data-source-id $DS_ID \
  --ingestion-job-id $JOB --region $AWS_REGION --query ingestionJob.statistics

Mira las estadísticas: deberías ver documentos nuevos o modificados indexados. Si aparecen fallidos, revisa que el JSON de metadatos sea válido y que no supere los límites de S3 Vectors (1 KB y 35 claves por vector).

Paso 3: filtros de metadatos

Guarda filtros.py:

import os, boto3
rt = boto3.client("bedrock-agent-runtime", region_name=os.environ["AWS_REGION"])
KB_ID = os.environ["KB_ID"]
PREGUNTA = "¿Cuántos días de vacaciones tengo en España?"

def buscar(filtro=None, k=4):
    cfg = {"numberOfResults": k}
    if filtro:
        cfg["filter"] = filtro
    r = rt.retrieve(knowledgeBaseId=KB_ID, retrievalQuery={"text": PREGUNTA},
                    retrievalConfiguration={"vectorSearchConfiguration": cfg})
    for x in r["retrievalResults"]:
        md = x.get("metadata", {})
        print(f"  {x['score']:.3f}  {x['location']['s3Location']['uri'].split('/')[-1]:<22}"
              f" anio={md.get('anio')} dep={md.get('departamento')}")

print("SIN filtro:"); buscar()
print("\nCON filtro (rrhh y anio >= 2026):")
buscar({"andAll": [
    {"equals": {"key": "departamento", "value": "rrhh"}},
    {"greaterThanOrEquals": {"key": "anio", "value": 2026}},
]})
python3 filtros.py

Sin filtro, el documento de 2024 compite con el vigente (y puede ganar). Con el filtro desaparece. Así se implementan en producción la vigencia y el aislamiento por tenant: el backend añade el filtro a partir de la identidad del usuario, nunca lo decide el cliente.

Paso 4: reranking en dos etapas

Guarda rerank.py. Recupera 10 candidatos y deja que Amazon Rerank 1.0 (disponible in-region en eu-central-1) elija los 3 mejores.

import os, time, boto3
REGION = os.environ["AWS_REGION"]; KB_ID = os.environ["KB_ID"]
rt = boto3.client("bedrock-agent-runtime", region_name=REGION)
RERANK_ARN = f"arn:aws:bedrock:{REGION}::foundation-model/amazon.rerank-v1:0"

def recuperar(pregunta, rerank=False, k=10, top=3):
    cfg = {"numberOfResults": k}
    if rerank:
        cfg["rerankingConfiguration"] = {
            "type": "BEDROCK_RERANKING_MODEL",
            "bedrockRerankingConfiguration": {
                "numberOfRerankedResults": top,
                "modelConfiguration": {"modelArn": RERANK_ARN},
            },
        }
    t0 = time.perf_counter()
    r = rt.retrieve(knowledgeBaseId=KB_ID, retrievalQuery={"text": pregunta},
                    retrievalConfiguration={"vectorSearchConfiguration": cfg})
    ms = (time.perf_counter() - t0) * 1000
    fuentes = [x["location"]["s3Location"]["uri"].split("/")[-1] for x in r["retrievalResults"]]
    return fuentes[:top], ms

if __name__ == "__main__":
    for p in ["¿Qué hago si la VPN me da un certificado caducado?",
              "¿Cuánto puedo gastar por noche de hotel?"]:
        base, t1 = recuperar(p)
        rr, t2 = recuperar(p, rerank=True)
        print(p)
        print(f"  sin rerank ({t1:.0f} ms): {base}")
        print(f"  con rerank ({t2:.0f} ms): {rr}\n")
python3 rerank.py

Compara el orden y la latencia: el reranking suele mejorar el primer puesto a cambio de unos cientos de milisegundos y un coste por consulta. Con un corpus de cinco documentos la diferencia será pequeña; la evaluación del paso 7 te da números en vez de impresiones.

Paso 5: descomposición de consultas

Una pregunta compuesta mezcla dos documentos. Guarda descomponer.py:

import os, boto3
REGION = os.environ["AWS_REGION"]; KB_ID = os.environ["KB_ID"]; CUENTA = os.environ["CUENTA"]
MODELO = f"arn:aws:bedrock:{REGION}:{CUENTA}:inference-profile/eu.amazon.nova-micro-v1:0"
rt = boto3.client("bedrock-agent-runtime", region_name=REGION)
PREGUNTA = ("¿Tiene más días de vacaciones un empleado en España o en Portugal, "
            "y cuánto es la dieta diaria en un viaje al extranjero?")
FILTRO = {"greaterThanOrEquals": {"key": "anio", "value": 2026}}

def rag(descomponer):
    kb = {"knowledgeBaseId": KB_ID, "modelArn": MODELO,
          "retrievalConfiguration": {"vectorSearchConfiguration": {"numberOfResults": 5, "filter": FILTRO}}}
    if descomponer:
        kb["orchestrationConfiguration"] = {"queryTransformationConfiguration": {"type": "QUERY_DECOMPOSITION"}}
    r = rt.retrieve_and_generate(input={"text": PREGUNTA},
        retrieveAndGenerateConfiguration={"type": "KNOWLEDGE_BASE", "knowledgeBaseConfiguration": kb})
    fuentes = sorted({ref["location"]["s3Location"]["uri"].split("/")[-1]
                      for c in r["citations"] for ref in c["retrievedReferences"]})
    print(("CON" if descomponer else "SIN"), "descomposición:\n ", r["output"]["text"], "\n  Fuentes:", fuentes, "\n")

rag(False)
rag(True)
python3 descomponer.py

Con la descomposición, Bedrock genera subconsultas (vacaciones España/Portugal y dietas) y recupera para cada una, así que las citas deberían incluir vacaciones.md y gastos.md.

Paso 6: plantilla de prompt propia con citas

La plantilla de generación de RetrieveAndGenerate admite marcadores: $search_results$ (obligatorio, los chunks), $output_format_instructions$ (necesario para que haya citas) y $current_time$. Guarda plantilla.py:

import os, boto3
REGION = os.environ["AWS_REGION"]; KB_ID = os.environ["KB_ID"]; CUENTA = os.environ["CUENTA"]
MODELO = f"arn:aws:bedrock:{REGION}:{CUENTA}:inference-profile/eu.amazon.nova-micro-v1:0"
rt = boto3.client("bedrock-agent-runtime", region_name=REGION)

PLANTILLA = """Eres el asistente interno de Cafés Montaña. Responde en español de España,
con tuteo y en un máximo de 3 frases, usando SOLO los resultados de búsqueda.
Si la respuesta no está en ellos, responde exactamente: "No consta en la documentación interna."

Resultados de búsqueda:
$search_results$

$output_format_instructions$"""

r = rt.retrieve_and_generate(
    input={"text": "¿Puedo teletrabajar si trabajo en una tienda?"},
    retrieveAndGenerateConfiguration={"type": "KNOWLEDGE_BASE", "knowledgeBaseConfiguration": {
        "knowledgeBaseId": KB_ID, "modelArn": MODELO,
        "generationConfiguration": {
            "promptTemplate": {"textPromptTemplate": PLANTILLA},
            "inferenceConfig": {"textInferenceConfig": {"temperature": 0, "maxTokens": 200}},
        },
    }},
)
print(r["output"]["text"])
print("Citas:", len(r["citations"]))
python3 plantilla.py

Prueba a quitar $output_format_instructions$ de la plantilla y vuelve a ejecutar: las citas desaparecen. Es un detalle que la documentación marca como obligatorio para mostrar citas.

Paso 7: evaluación rápida de la recuperación y de las respuestas

No optimices a ciegas. Un conjunto de oro (golden dataset) pequeño con la fuente esperada permite comparar configuraciones con métricas de recuperación:

  • hit@1 / hit@3: porcentaje de preguntas cuyo documento correcto está en el primer puesto / entre los tres primeros.
  • MRR (Mean Reciprocal Rank): media de 1/posición del documento correcto (1 si es el primero, 0,5 si es el segundo…).
  • Latencia media de recuperación.

Y para la respuesta generada, un LLM como juez puntúa de 1 a 5 si la respuesta es correcta y está sustentada en el contexto. Guarda evaluar.py:

import os, re, json, statistics, boto3
from rerank import recuperar

REGION = os.environ["AWS_REGION"]; KB_ID = os.environ["KB_ID"]; CUENTA = os.environ["CUENTA"]
MODELO = f"arn:aws:bedrock:{REGION}:{CUENTA}:inference-profile/eu.amazon.nova-micro-v1:0"
rt = boto3.client("bedrock-agent-runtime", region_name=REGION)
brt = boto3.client("bedrock-runtime", region_name=REGION)

ORO = [
    ("¿Cuántos días libres al año tengo en España?", "vacaciones.md", "23 días laborables"),
    ("¿Hasta cuándo puedo disfrutar las vacaciones que me sobren?", "vacaciones.md", "hasta el 31 de marzo"),
    ("¿Me pagan algo por trabajar desde casa?", "teletrabajo.md", "30 EUR al mes"),
    ("¿Pueden teletrabajar los empleados de planta?", "teletrabajo.md", "no"),
    ("¿Cuánto es la dieta diaria en el extranjero?", "gastos.md", "60 EUR"),
    ("¿Qué código de proyecto uso para viajes comerciales?", "gastos.md", "VIA-2026-COM"),
    ("¿En cuánto tiempo debo avisar de un incidente de seguridad?", "seguridad.md", "menos de 1 hora"),
    ("¿Qué significa ERR-4012?", "seguridad.md", "certificado caducado"),
]

def metricas(rerank):
    hits1 = hits3 = 0; rr = []; lat = []
    for pregunta, esperado, _ in ORO:
        fuentes, ms = recuperar(pregunta, rerank=rerank, k=10, top=3)
        lat.append(ms)
        pos = fuentes.index(esperado) + 1 if esperado in fuentes else None
        hits1 += pos == 1; hits3 += pos is not None; rr.append(1 / pos if pos else 0)
    n = len(ORO)
    return {"hit@1": hits1 / n, "hit@3": hits3 / n, "MRR": round(statistics.mean(rr), 3),
            "latencia_ms": round(statistics.mean(lat))}

def generar(pregunta):
    r = rt.retrieve_and_generate(input={"text": pregunta},
        retrieveAndGenerateConfiguration={"type": "KNOWLEDGE_BASE", "knowledgeBaseConfiguration": {
            "knowledgeBaseId": KB_ID, "modelArn": MODELO,
            "retrievalConfiguration": {"vectorSearchConfiguration": {"numberOfResults": 4,
                "filter": {"greaterThanOrEquals": {"key": "anio", "value": 2026}}}}}})
    return r["output"]["text"]

def juez(pregunta, referencia, respuesta):
    prompt = (f"Evalúa la RESPUESTA a la PREGUNTA comparándola con el DATO DE REFERENCIA.\n"
              f"PREGUNTA: {pregunta}\nDATO DE REFERENCIA: {referencia}\nRESPUESTA: {respuesta}\n"
              "Devuelve solo un JSON con las claves nota (entero de 1 a 5, 5 = correcta y completa) "
              "y motivo (una frase).")
    r = brt.converse(modelId="eu.amazon.nova-micro-v1:0",
                     messages=[{"role": "user", "content": [{"text": prompt}]}],
                     inferenceConfig={"temperature": 0, "maxTokens": 150})
    texto = r["output"]["message"]["content"][0]["text"]
    m = re.search(r"\{.*\}", texto, re.S)
    try:
        return json.loads(m.group(0)) if m else {"nota": None, "motivo": texto[:80]}
    except json.JSONDecodeError:
        return {"nota": None, "motivo": "JSON inválido del juez"}

print("Recuperación SIN rerank:", metricas(False))
print("Recuperación CON rerank:", metricas(True))
print("\nLLM como juez:")
notas = []
for pregunta, _, referencia in ORO:
    v = juez(pregunta, referencia, generar(pregunta))
    notas.append(v.get("nota") or 0)
    print(f"  {v.get('nota')}  {pregunta}  -> {v.get('motivo')}")
print("Nota media:", round(statistics.mean(notas), 2))
python3 evaluar.py

Interpreta los resultados como un arquitecto:

  • Si hit@3 es alto pero hit@1 bajo, el reranking debería ayudar.
  • Si una pregunta falla en todas las configuraciones, el problema está antes: chunking, redacción del documento o falta de búsqueda híbrida (por ejemplo, códigos como VIA-2026-COM).
  • Fíjate en el coste de latencia del reranking. Para una evaluación seria usarías Amazon Bedrock Evaluations (evaluación de RAG con métricas de recuperación y de generación y LLM como juez gestionado), que verás en el módulo 10.

Comprueba que funciona

  • Sin filtro aparece vacaciones-2024.md; con el filtro anio >= 2026 no aparece.
  • rerank.py muestra resultados y latencias con y sin reranking.
  • Con descomposición, las citas incluyen vacaciones.md y gastos.md.
  • Sin $output_format_instructions$ la respuesta no trae citas.
  • evaluar.py imprime hit@1, hit@3, MRR y latencia para ambas configuraciones, y una nota media del juez.

Limpieza

Esta limpieza elimina también los recursos del lab 05. Orden: fuente de datos → Knowledge Base → índice → vector bucket → bucket de documentos → rol.

aws bedrock-agent delete-data-source --knowledge-base-id $KB_ID --data-source-id $DS_ID --region $AWS_REGION
sleep 20
aws bedrock-agent delete-knowledge-base --knowledge-base-id $KB_ID --region $AWS_REGION
sleep 20
aws s3vectors delete-index --vector-bucket-name $VBUCKET --index-name $INDICE --region $AWS_REGION
aws s3vectors delete-vector-bucket --vector-bucket-name $VBUCKET --region $AWS_REGION
aws s3 rb s3://$BUCKET_DOCS --force
aws iam delete-role-policy --role-name $ROL_KB --policy-name lab05-kb-permisos
aws iam delete-role --role-name $ROL_KB
rm -rf ~/lab05 ~/lab06

# Comprobación: no deben quedar KB ni vector buckets del lab
aws bedrock-agent list-knowledge-bases --region $AWS_REGION --query 'knowledgeBaseSummaries[].name'
aws s3vectors list-vector-buckets --region $AWS_REGION --query 'vectorBuckets[].vectorBucketName'

Preguntas para pensar como arquitecto

1. Tu empresa tiene 300 clientes y un único índice. ¿Cómo garantizas que nadie recupere documentos de otro cliente?

Cada documento lleva un metadato tenant_id. El backend (Lambda detrás de API Gateway con autorizador de Cognito) obtiene el tenant del token del usuario y añade siempre el filtro equals de tenant_id a Retrieve; el cliente nunca envía el filtro. La aplicación solo puede llamar a la KB a través de ese backend (IAM). Si el enunciado exigiera aislamiento criptográfico o de red por cliente, índices o KB separados.

2. El reranking ha mejorado el MRR pero la latencia p95 supera el objetivo de 2 s. ¿Qué opciones tienes?

Reducir los candidatos que se rerankean (p. ej., 10 en vez de 50), rerankear solo cuando la puntuación del primer resultado es baja, cachear respuestas de preguntas frecuentes (caché semántica), usar streaming para mejorar la latencia percibida, o valorar la Managed Knowledge Base con reranker gestionado. Medir con p95 y no con la media.

3. Una pregunta compuesta sigue fallando incluso con QUERY_DECOMPOSITION. ¿Qué harías?

Revisar primero si cada subpregunta por separado recupera bien (si no, el problema es de chunking o de búsqueda). Después, implementar mi propia descomposición: un FM genera subpreguntas, Step Functions lanza un Retrieve por subpregunta en un estado Map, fusiono y deduplico chunks, rerankeo y genero. Otra opción es la recuperación agéntica de la Managed Knowledge Base.

4. ¿Por qué no basta con la nota del LLM juez para decidir si pasar a producción?

Porque el juez también puede equivocarse y tiene sesgos (prefiere respuestas largas, por ejemplo). Hay que combinar métricas deterministas de recuperación (hit@k, MRR), métricas de generación (corrección frente a referencia, fidelidad al contexto), revisión humana de una muestra y pruebas de regresión automatizadas en el pipeline, con umbrales como quality gates.

5. ¿Qué cambiarías para que las políticas caducadas no vuelvan a colarse sin depender de filtros?

Gobierno del contenido en la fuente: al publicar una nueva versión, mover la antigua a un prefijo no incluido (o borrarla) y sincronizar; política de ciclo de vida en S3 para archivar versiones antiguas; metadatos de vigencia obligatorios validados en el pipeline de ingesta (Lambda que rechaza documentos sin anio o vigente). Los filtros quedan como segunda línea de defensa.


Volver al módulo