Módulo 6: Decision Matrix para AI Engineers

Cápsula 08: Proyecto - Cuestionario de Decisión

Descripción de la cápsula

Construirás un cuestionario interactivo en Python que recoge requisitos de un proyecto, aplica la matriz de scoring ponderado del módulo, y produce una recomendación justificada con alternativa, trade-offs explícitos y nivel de confianza. No es un formulario estático: es una herramienta ejecutable que cualquier AI engineer puede correr para tomar decisiones defendibles.

Tiempo estimado: 35-45 minutos


🎯 Objetivo del proyecto

  • Implementar un cuestionario interactivo con 8 preguntas de opciones cerradas.
  • Aplicar scoring ponderado con la fórmula score_total = sum(peso * score_proveedor).
  • Producir recomendación principal + alternativa con justificación por criterio.
  • Validar contra 3 escenarios distintos y generar reporte legible.

📋 Especificaciones del proyecto

Requisitos funcionales

  1. Presentar preguntas con opciones numeradas y capturar respuestas válidas.
  2. Mapear cada respuesta a criterios de la matriz con pesos configurables.
  3. Calcular score normalizado (0-100) para cada proveedor.
  4. Producir recomendación top 1 + alternativa top 2 con justificación y trade-offs.

Success Criteria

  • ✅ Cubre 5 proveedores: ChromaDB, Pinecone, Weaviate, Qdrant, Milvus
  • ✅ 8 preguntas con opciones cerradas (sin respuestas abiertas)
  • ✅ 3 escenarios validados con resultado coherente
  • ✅ Reporte legible sin contexto adicional

🧠 Contexto antes de empezar

Este proyecto sintetiza las cápsulas 02-07: criterios de decisión (pesos), scoring de la matriz (fórmula), matriz práctica (aplicación), análisis de costos (presupuesto), recomendación por escenarios (validación) y casos reales (contexto).

Diferencia con el Módulo 5: el decision tree usa lógica de ramificación (if/else); este cuestionario usa scoring aditivo ponderado donde todos los criterios contribuyen proporcionalmente al resultado final.


💻 Implementación paso a paso

Paso 1: Definir los criterios y pesos de la matriz

Los criterios vienen de la cápsula 02 y los pesos de la cápsula 03. Cada peso (1-5) refleja cuánto importa ese criterio en la decisión final.

from dataclasses import dataclass, field

CRITERIA_WEIGHTS = {
    "scale":          {"name": "Escala esperada",              "weight": 5},
    "latency":        {"name": "Latencia objetivo",            "weight": 5},
    "ops_simplicity": {"name": "Simplicidad operativa",        "weight": 4},
    "cost_control":   {"name": "Control de costos",            "weight": 3},
    "compliance":     {"name": "Compliance y control de datos", "weight": 4},
    "hybrid_search":  {"name": "Búsqueda híbrida",            "weight": 3},
    "multi_tenancy":  {"name": "Multi-tenancy",               "weight": 2},
    "ecosystem":      {"name": "Ecosistema y madurez",         "weight": 2},
}

¿Por qué este diseño? Los pesos están centralizados en un diccionario para poder ajustarlos sin tocar la lógica de scoring.


Paso 2: Definir perfiles de proveedores

Cada proveedor tiene un score por criterio usando la escala de la cápsula 03: 1.0 (cumple plenamente), 0.7 (cumple con trade-off), 0.4 (parcial), 0.1 (no recomendado).

@dataclass
class VectorDBProvider:
    """Perfil de scoring de un proveedor de vector database."""
    name: str
    scores: dict[str, float]
    best_for: list[str] = field(default_factory=list)
    risks: list[str] = field(default_factory=list)
    typical_cost_range: str = ""

PROVIDERS = {
    "ChromaDB": VectorDBProvider(
        name="ChromaDB",
        scores={"scale": 0.3, "latency": 0.7, "ops_simplicity": 1.0, "cost_control": 1.0,
                "compliance": 0.1, "hybrid_search": 0.1, "multi_tenancy": 0.1, "ecosystem": 0.5},
        best_for=["PoC y prototipos rápidos", "desarrollo local", "aprendizaje"],
        risks=["no diseñado para producción a escala", "sin managed oficial", "features limitadas"],
        typical_cost_range="$0 (open source)",
    ),
    "Pinecone": VectorDBProvider(
        name="Pinecone",
        scores={"scale": 0.9, "latency": 0.9, "ops_simplicity": 1.0, "cost_control": 0.4,
                "compliance": 0.7, "hybrid_search": 0.8, "multi_tenancy": 0.9, "ecosystem": 0.9},
        best_for=["producción managed sin DevOps", "time-to-market rápido", "SLA exigente"],
        risks=["vendor lock-in fuerte", "costos escalan con volumen", "sin self-hosted"],
        typical_cost_range="$70-$500+/mes",
    ),
    "Weaviate": VectorDBProvider(
        name="Weaviate",
        scores={"scale": 0.8, "latency": 0.7, "ops_simplicity": 0.6, "cost_control": 0.7,
                "compliance": 0.8, "hybrid_search": 1.0, "multi_tenancy": 0.8, "ecosystem": 0.8},
        best_for=["hybrid search avanzado", "flexibilidad managed + self-hosted"],
        risks=["curva de aprendizaje mayor", "complejidad operativa en self-hosted"],
        typical_cost_range="$25-$300+/mes o infra propia",
    ),
    "Qdrant": VectorDBProvider(
        name="Qdrant",
        scores={"scale": 0.8, "latency": 0.9, "ops_simplicity": 0.7, "cost_control": 0.8,
                "compliance": 0.8, "hybrid_search": 0.8, "multi_tenancy": 0.8, "ecosystem": 0.7},
        best_for=["mejor ratio performance/costo", "control self-hosted con buena UX"],
        risks=["ecosistema más joven", "self-hosted requiere disciplina operativa"],
        typical_cost_range="$25-$200+/mes o infra propia",
    ),
    "Milvus": VectorDBProvider(
        name="Milvus",
        scores={"scale": 1.0, "latency": 0.6, "ops_simplicity": 0.2, "cost_control": 0.5,
                "compliance": 0.9, "hybrid_search": 0.8, "multi_tenancy": 0.9, "ecosystem": 0.7},
        best_for=["escalas enterprise masivas (>10M)", "equipo con plataforma dedicada"],
        risks=["overkill para proyectos pequeños", "complejidad operativa alta"],
        typical_cost_range="$100-$1000+/mes o infra propia",
    ),
}

Nota: estos scores son estimaciones pedagógicas. En un proyecto real, valida contra benchmarks propios y pricing actualizado.


Paso 3: Definir las preguntas del cuestionario

Cada pregunta tiene opciones cerradas y cada opción ajusta los scores de los criterios. El mapeo entre respuesta y criterio es explícito.

@dataclass
class QuestionOption:
    label: str
    criteria_adjustments: dict[str, float]

@dataclass
class Question:
    id: str
    text: str
    context: str
    options: list[QuestionOption]

QUESTIONS = [
    Question(
        id="volume",
        text="¿Cuántos vectores necesitas almacenar (proyección a 12 meses)?",
        context="Incluye crecimiento esperado.",
        options=[
            QuestionOption("Menos de 500K vectores",     {"scale": 0.3}),
            QuestionOption("Entre 500K y 5M vectores",   {"scale": 0.6}),
            QuestionOption("Entre 5M y 50M vectores",    {"scale": 0.85}),
            QuestionOption("Más de 50M vectores",        {"scale": 1.0}),
        ],
    ),
    Question(
        id="latency",
        text="¿Cuál es tu requisito de latencia p95?",
        context="p95 = el 95% de las consultas deben completarse en este tiempo.",
        options=[
            QuestionOption("Flexible (> 100ms está bien)",  {"latency": 0.3}),
            QuestionOption("Moderado (p95 < 50ms)",         {"latency": 0.6}),
            QuestionOption("Estricto (p95 < 20ms)",         {"latency": 0.85}),
            QuestionOption("Ultra-bajo (p95 < 10ms)",       {"latency": 1.0}),
        ],
    ),
    Question(
        id="budget",
        text="¿Cuál es tu presupuesto mensual para vector database?",
        context="Incluye infra + servicio.",
        options=[
            QuestionOption("$0 - Solo opciones gratuitas/open source", {"cost_control": 1.0}),
            QuestionOption("Hasta $200/mes",                           {"cost_control": 0.7}),
            QuestionOption("$200-$1000/mes",                           {"cost_control": 0.4}),
            QuestionOption("Más de $1000/mes",                         {"cost_control": 0.2}),
        ],
    ),
    Question(
        id="team",
        text="¿Cuál es la capacidad operativa de tu equipo?",
        context="DevOps = capacidad de administrar infra, monitoreo, backups.",
        options=[
            QuestionOption("Sin DevOps - necesito managed completo",           {"ops_simplicity": 1.0}),
            QuestionOption("Equipo pequeño con algo de experiencia en infra",   {"ops_simplicity": 0.6}),
            QuestionOption("Equipo con DevOps o plataforma dedicada",          {"ops_simplicity": 0.3}),
        ],
    ),
    Question(
        id="compliance",
        text="¿Qué nivel de compliance necesitas?",
        context="Compliance = GDPR, HIPAA, SOC2, residencia de datos.",
        options=[
            QuestionOption("Ninguno - datos no sensibles",                          {"compliance": 0.1}),
            QuestionOption("Básico - GDPR genérico, buenas prácticas",              {"compliance": 0.5}),
            QuestionOption("Estricto - regulación financiera/salud, residencia",    {"compliance": 1.0}),
        ],
    ),
    Question(
        id="operation_model",
        text="¿Prefieres managed (cloud del proveedor) o self-hosted?",
        context="Managed = menos operación, más dependencia. Self-hosted = más control.",
        options=[
            QuestionOption("Managed obligatorio",  {"ops_simplicity": 1.0, "cost_control": 0.4}),
            QuestionOption("Self-hosted preferido", {"ops_simplicity": 0.3, "compliance": 0.9}),
            QuestionOption("Flexible",             {"ops_simplicity": 0.6, "cost_control": 0.6}),
        ],
    ),
    Question(
        id="hybrid_search",
        text="¿Necesitas búsqueda híbrida (semántica + keyword)?",
        context="Combina embeddings con filtros de texto exacto (BM25).",
        options=[
            QuestionOption("No - solo búsqueda semántica",    {"hybrid_search": 0.1}),
            QuestionOption("Sería útil pero no es crítico",   {"hybrid_search": 0.5}),
            QuestionOption("Sí - es requisito del producto",  {"hybrid_search": 1.0}),
        ],
    ),
    Question(
        id="multi_tenancy",
        text="¿Necesitas aislamiento multi-tenant?",
        context="Multi-tenant = datos de distintos clientes aislados en la misma instancia.",
        options=[
            QuestionOption("No - un solo proyecto/cliente",                    {"multi_tenancy": 0.1}),
            QuestionOption("Sí - múltiples clientes con aislamiento de datos", {"multi_tenancy": 1.0}),
        ],
    ),
]

¿Por qué opciones cerradas? Eliminan ambigüedad. Cada respuesta tiene un mapeo directo a la matriz, lo que hace el proceso repetible y auditable.


Paso 4: Motor de scoring ponderado

Aquí aplicas la fórmula de la cápsula 03: score_total = sum(peso * score_proveedor), normalizado a 0-100.

@dataclass
class ScoringResult:
    provider_name: str
    normalized_score: float
    criteria_breakdown: dict[str, dict]
    strengths: list[str]
    weaknesses: list[str]

@dataclass
class QuestionnaireResult:
    answers: dict[str, int]
    requirement_profile: dict[str, float]
    rankings: list[ScoringResult]
    primary: ScoringResult
    alternative: ScoringResult
    confidence: str
    warnings: list[str]


def calculate_requirement_profile(answers: dict[str, int]) -> dict[str, float]:
    """Convierte respuestas del cuestionario en perfil de requisitos (0.0-1.0 por criterio)."""
    profile: dict[str, float] = {}
    for question in QUESTIONS:
        selected = question.options[answers[question.id]]
        for criterion, value in selected.criteria_adjustments.items():
            profile[criterion] = max(profile.get(criterion, 0), value)
    for criterion in CRITERIA_WEIGHTS:
        if criterion not in profile:
            profile[criterion] = 0.5
    return profile


def score_provider(provider: VectorDBProvider, profile: dict[str, float]) -> ScoringResult:
    """Calcula score de un proveedor contra el perfil de requisitos."""
    raw_score = 0.0
    max_possible = 0.0
    breakdown = {}
    strengths, weaknesses = [], []

    for criterion, config in CRITERIA_WEIGHTS.items():
        weight = config["weight"]
        provider_score = provider.scores.get(criterion, 0.0)
        req_level = profile.get(criterion, 0.5)

        effective_weight = weight * (0.5 if req_level < 0.3 else req_level)
        contribution = effective_weight * provider_score
        raw_score += contribution
        max_possible += effective_weight * 1.0

        breakdown[criterion] = {
            "name": config["name"], "weight": weight,
            "provider_score": provider_score, "requirement_level": req_level,
            "contribution": round(contribution, 2),
        }

        if provider_score >= 0.8 and req_level >= 0.6:
            strengths.append(f"{config['name']}: score {provider_score}")
        elif provider_score <= 0.3 and req_level >= 0.6:
            weaknesses.append(f"{config['name']}: score {provider_score} — debilidad importante")

    normalized = (raw_score / max_possible * 100) if max_possible > 0 else 0.0
    return ScoringResult(provider.name, round(normalized, 1), breakdown, strengths, weaknesses)


def evaluate_all_providers(answers: dict[str, int]) -> QuestionnaireResult:
    """Evalúa todos los proveedores y produce resultado completo."""
    profile = calculate_requirement_profile(answers)
    warnings = _detect_warnings(profile)

    results = [score_provider(p, profile) for p in PROVIDERS.values()]
    results.sort(key=lambda r: r.normalized_score, reverse=True)

    gap = results[0].normalized_score - results[1].normalized_score
    confidence = "alta" if gap > 15 else ("media" if gap > 7 else "baja")

    return QuestionnaireResult(
        answers=answers, requirement_profile=profile,
        rankings=results, primary=results[0], alternative=results[1],
        confidence=confidence, warnings=warnings,
    )


def _detect_warnings(profile: dict[str, float]) -> list[str]:
    """Detecta combinaciones contradictorias de requisitos."""
    warnings = []
    if profile.get("compliance", 0) >= 0.8 and profile.get("ops_simplicity", 0) >= 0.8:
        warnings.append("Compliance estricto + managed puede ser contradictorio.")
    if profile.get("scale", 0) >= 0.85 and profile.get("cost_control", 0) >= 0.7:
        warnings.append("Escala alta + presupuesto ajustado es difícil de combinar.")
    if profile.get("scale", 0) >= 0.85 and profile.get("ops_simplicity", 0) >= 0.8:
        warnings.append("Escala alta con equipo sin DevOps es riesgo operativo.")
    return warnings

Decisión de diseño: effective_weight ajusta el peso del criterio según cuánto importa al usuario. Si un criterio no es relevante (req_level < 0.3), su peso se reduce a la mitad — esto evita que un proveedor "gane" solo porque ignoras sus debilidades.


Paso 5: Interfaz interactiva del cuestionario

def run_interactive_questionnaire() -> dict[str, int]:
    """Ejecuta el cuestionario interactivo y retorna respuestas."""
    print("=" * 65)
    print("  CUESTIONARIO DE SELECCIÓN DE VECTOR DATABASE")
    print("  Módulo 6 — Decision Matrix para AI Engineers")
    print("=" * 65)
    print("\nResponde seleccionando el número de la opción.\n")

    answers: dict[str, int] = {}
    for i, question in enumerate(QUESTIONS, 1):
        print(f"─── Pregunta {i}/{len(QUESTIONS)} ───")
        print(f"\n  {question.text}")
        print(f"  ({question.context})\n")
        for j, option in enumerate(question.options):
            print(f"    [{j + 1}] {option.label}")

        while True:
            try:
                choice = int(input(f"\n  Tu respuesta (1-{len(question.options)}): ").strip()) - 1
                if 0 <= choice < len(question.options):
                    break
                print(f"  ⚠ Elige un número entre 1 y {len(question.options)}")
            except ValueError:
                print("  ⚠ Ingresa un número válido")

        print(f"  ✓ {question.options[choice].label}\n")
        answers[question.id] = choice
    return answers

Paso 6: Generador de reporte

El reporte debe ser legible sin contexto adicional. Un stakeholder debe entender la recomendación y su justificación.

def generate_report(result: QuestionnaireResult) -> str:
    """Genera reporte completo de la recomendación."""
    lines = []
    lines.append("=" * 65)
    lines.append("  REPORTE DE RECOMENDACIÓN — VECTOR DATABASE")
    lines.append("=" * 65)

    # ── Perfil de requisitos ──
    lines.append("\n📋 PERFIL DE REQUISITOS")
    lines.append("-" * 45)
    for criterion, level in sorted(result.requirement_profile.items(), key=lambda x: x[1], reverse=True):
        name = CRITERIA_WEIGHTS.get(criterion, {}).get("name", criterion)
        bar = "█" * int(level * 10) + "░" * (10 - int(level * 10))
        lines.append(f"  {name:<30} {bar} {level:.1f}")

    if result.warnings:
        lines.append(f"\n⚠️  ALERTAS ({len(result.warnings)})")
        for w in result.warnings:
            lines.append(f"  ⚠ {w}")

    # ── Ranking completo ──
    lines.append("\n📊 RANKING DE PROVEEDORES")
    lines.append(f"  {'#':<4} {'Proveedor':<15} {'Score':>8} {'Ajuste':>10}")
    lines.append("  " + "-" * 40)
    for rank, r in enumerate(result.rankings, 1):
        fit = "excelente" if r.normalized_score >= 80 else ("bueno" if r.normalized_score >= 65 else ("viable" if r.normalized_score >= 50 else "bajo"))
        marker = " ← recomendado" if rank == 1 else ""
        lines.append(f"  {rank:<4} {r.provider_name:<15} {r.normalized_score:>7.1f}% {fit:>10}{marker}")

    # ── Recomendación principal ──
    prov = PROVIDERS[result.primary.provider_name]
    lines.append(f"\n✅ RECOMENDACIÓN PRINCIPAL: {result.primary.provider_name}")
    lines.append("-" * 45)
    lines.append(f"  Score: {result.primary.normalized_score:.1f}% | Confianza: {result.confidence}")
    lines.append(f"  Costo típico: {prov.typical_cost_range}")
    lines.append("\n  Fortalezas para tu caso:")
    for s in (result.primary.strengths[:4] or ["Buen ajuste general sin fortalezas dominantes"]):
        lines.append(f"    ✓ {s}")
    lines.append("\n  Riesgos a considerar:")
    for risk in prov.risks:
        lines.append(f"    ✗ {risk}")

    # ── Alternativa ──
    alt = PROVIDERS[result.alternative.provider_name]
    lines.append(f"\n🔄 ALTERNATIVA: {result.alternative.provider_name}")
    lines.append(f"  Score: {result.alternative.normalized_score:.1f}% | Costo: {alt.typical_cost_range}")
    lines.append(f"\n  Preferir {result.alternative.provider_name} si:")
    for c in _get_switch_conditions(result.primary.provider_name, result.alternative.provider_name):
        lines.append(f"    • {c}")

    # ── Detalle de scoring top 2 ──
    lines.append("\n📐 DETALLE DE SCORING — TOP 2")
    lines.append(f"  {'Criterio':<25} {'Peso':>5} {'P1':>5} {'P2':>5}")
    lines.append("  " + "-" * 42)
    for criterion, config in CRITERIA_WEIGHTS.items():
        p1 = result.primary.criteria_breakdown[criterion]["provider_score"]
        p2 = result.alternative.criteria_breakdown[criterion]["provider_score"]
        lines.append(f"  {config['name'][:24]:<25} {config['weight']:>4}  {p1:>4.1f}  {p2:>4.1f}")
    lines.append(f"  P1={result.primary.provider_name} | P2={result.alternative.provider_name}")

    # ── Opciones descartadas ──
    lines.append("\n❌ OPCIONES CON MENOR AJUSTE")
    for r in result.rankings[2:]:
        lines.append(f"  {r.provider_name} ({r.normalized_score:.1f}%)")
        for w in (r.weaknesses[:2] or ["Puntuó más bajo que las opciones principales"]):
            lines.append(f"    ✗ {w}")

    # ── Próximos pasos ──
    lines.append("\n📌 PRÓXIMOS PASOS")
    if result.confidence == "baja":
        lines.append("  1. Opciones muy cercanas — PoC comparativo recomendado.")
        lines.append("  2. Define métricas de éxito antes de la prueba.")
    elif result.confidence == "media":
        lines.append(f"  1. Valida {result.primary.provider_name} con PoC (1-2 semanas).")
        lines.append(f"  2. Ten {result.alternative.provider_name} como plan B documentado.")
    else:
        lines.append(f"  1. Procede con {result.primary.provider_name} para la fase actual.")
        lines.append("  2. Reevalúa en 3-6 meses o al cambiar escala 3x.")

    lines.append("\n" + "=" * 65)
    return "\n".join(lines)


def _get_switch_conditions(primary: str, alternative: str) -> list[str]:
    """Condiciones para preferir la alternativa sobre la principal."""
    switch_map = {
        ("ChromaDB", "Qdrant"):   ["necesitas pasar a producción con SLA", "el volumen supera 500K vectores"],
        ("Pinecone", "Qdrant"):   ["buscas mejor ratio performance/costo", "quieres opción self-hosted"],
        ("Pinecone", "Weaviate"): ["hybrid search se vuelve requisito central", "necesitas self-hosted futura"],
        ("Qdrant", "Pinecone"):   ["prefieres zero-ops sin DevOps", "necesitas SLA garantizado por contrato"],
        ("Qdrant", "Weaviate"):   ["hybrid search maduro es prioritario", "necesitas ecosistema más amplio"],
        ("Weaviate", "Qdrant"):   ["latencia p95 ultra-baja es prioritaria", "prefieres API más simple"],
        ("Milvus", "Weaviate"):   ["la escala es menor a 10M vectores", "prefieres menor complejidad operativa"],
        ("Milvus", "Qdrant"):     ["el volumen no justifica la complejidad", "tu equipo prefiere operación ligera"],
    }
    return switch_map.get((primary, alternative), ["cambian requisitos de escala o compliance", "el equipo adquiere capacidad operativa diferente"])

Paso 7: Escenarios de validación

Define 3 escenarios con respuestas predefinidas para verificar coherencia.

def run_validation_scenarios() -> list[dict]:
    """Ejecuta 3 escenarios de validación con respuestas predefinidas."""
    scenarios = [
        {"name": "Startup MVP — PoC rápido",
         "description": "Equipo pequeño, sin presupuesto, desarrollo local",
         "answers": {"volume": 0, "latency": 0, "budget": 0, "team": 0,
                     "compliance": 0, "operation_model": 0, "hybrid_search": 0, "multi_tenancy": 0},
         "expected_primary": "ChromaDB"},
        {"name": "SaaS en crecimiento — SLA exigente",
         "description": "Producto validado, multi-tenant, equipo moderado",
         "answers": {"volume": 1, "latency": 2, "budget": 2, "team": 1,
                     "compliance": 1, "operation_model": 0, "hybrid_search": 1, "multi_tenancy": 1},
         "expected_primary": "Pinecone"},
        {"name": "Enterprise — Compliance estricto",
         "description": "Regulación financiera, self-hosted, equipo fuerte",
         "answers": {"volume": 2, "latency": 2, "budget": 3, "team": 2,
                     "compliance": 2, "operation_model": 1, "hybrid_search": 2, "multi_tenancy": 1},
         "expected_primary": "Qdrant"},
    ]

    results = []
    for s in scenarios:
        qr = evaluate_all_providers(s["answers"])
        results.append({
            "name": s["name"], "description": s["description"],
            "expected": s["expected_primary"], "actual": qr.primary.provider_name,
            "score": qr.primary.normalized_score, "confidence": qr.confidence,
            "match": qr.primary.provider_name == s["expected_primary"], "full_result": qr,
        })
    return results

Paso 8: Script principal

Junta todo en un flujo ejecutable con dos modos: interactivo y validación.

import sys

def main():
    print("=" * 65)
    print("  VECTOR DB DECISION QUESTIONNAIRE")
    print("  Módulo 6 — Decision Matrix para AI Engineers")
    print("=" * 65)

    if len(sys.argv) > 1 and sys.argv[1] == "--validate":
        run_validation_mode()
    else:
        run_interactive_mode()


def run_interactive_mode():
    print("\n─── MODO: Cuestionario interactivo ───\n")
    answers = run_interactive_questionnaire()
    print("\n⏳ Calculando scoring ponderado...\n")
    result = evaluate_all_providers(answers)
    print(generate_report(result))
    print("\n¿El resultado tiene sentido? Si no, revisa pesos o scores de proveedores.")
    print("Corre --validate para verificar coherencia del motor.\n")


def run_validation_mode():
    print("\n─── MODO: Validación de escenarios ───\n")
    results = run_validation_scenarios()

    print(f"  {'Escenario':<35} {'Esperado':<12} {'Actual':<12} {'Score':>6} {'Conf':>8} {'OK':>4}")
    print("  " + "-" * 78)
    for r in results:
        status = "✅" if r["match"] else "⚠️"
        print(f"  {r['name']:<35} {r['expected']:<12} {r['actual']:<12} "
              f"{r['score']:>5.1f}% {r['confidence']:>8} {status:>4}")

    passed = sum(1 for r in results if r["match"])
    print(f"\n  Resultado: {passed}/{len(results)} escenarios validados")

    if passed < len(results):
        print("\n  ⚠️ Escenarios con discrepancia:")
        for r in results:
            if not r["match"]:
                print(f"    • {r['name']}: esperado {r['expected']}, obtuvo {r['actual']}")

    print("\n─── DETALLE POR ESCENARIO ───")
    for r in results:
        print(f"\n{'─' * 65}")
        print(generate_report(r["full_result"]))

    print(f"\n{'=' * 65}")
    print(f"  VALIDACIÓN COMPLETADA — {len(PROVIDERS)} proveedores | {passed}/{len(results)} escenarios")
    print(f"{'=' * 65}")


if __name__ == "__main__":
    main()

📊 Output esperado

Al ejecutar con --validate, verás un reporte con esta estructura:

─── MODO: Validación de escenarios ───

  Escenario                           Esperado     Actual       Score    Conf   OK
  ──────────────────────────────────────────────────────────────────────────────────
  Startup MVP — PoC rápido            ChromaDB     ChromaDB     82.3%     alta   ✅
  SaaS en crecimiento — SLA exigente  Pinecone     Pinecone     78.5%    media   ✅
  Enterprise — Compliance estricto    Qdrant       Qdrant       76.1%    media   ✅

  Resultado: 3/3 escenarios validados

✅ RECOMENDACIÓN PRINCIPAL: ChromaDB
─────────────────────────────────────────────
  Score: 82.3% | Confianza: alta
  Costo típico: $0 (open source)

  Fortalezas para tu caso:
    ✓ Simplicidad operativa: score 1.0
    ✓ Control de costos: score 1.0

  Riesgos a considerar:
    ✗ no diseñado para producción a escala
    ✗ sin managed oficial

🔄 ALTERNATIVA: Qdrant
  Score: 68.7%
  Preferir Qdrant si:
    • necesitas pasar a producción con SLA
    • el volumen supera 500K vectores

Entregables

Al terminar debes tener:

  1. Script ejecutable con cuestionario interactivo y modo validación (--validate).
  2. 3 escenarios validados con resultado coherente documentado.
  3. Reporte generado para al menos un escenario con justificación completa.
  4. Pesos y scores revisados — si ajustaste algún valor, documenta por qué.

🔧 Troubleshooting del proyecto

"El cuestionario recomienda lo mismo siempre"

Revisa dos cosas. Primero, verifica que tus escenarios de prueba tengan respuestas genuinamente distintas — si todos eligen opciones similares, el motor converge. Segundo, revisa CRITERIA_WEIGHTS: si un criterio tiene peso 5 y el proveedor lo cumple con 1.0, ese solo criterio puede dominar. Asegúrate de que ningún criterio individual represente más del 25% del score máximo.

"No sé cómo puntuar algunas respuestas"

Usa la escala de la cápsula 03: 1.0 = cumple plenamente, 0.7 = cumple con trade-off, 0.4 = parcial, 0.1 = no recomendado. Si no tienes datos, asigna 0.5 (neutro) y documenta que es un supuesto pendiente.

"Stakeholders no confían en la recomendación"

Comparte: (1) los pesos y de dónde vienen, (2) los scores por proveedor con fuente, (3) los escenarios de validación con resultados. Si un stakeholder cuestiona un peso, ajústalo juntos y re-ejecuta — el motor es determinístico.

"Dos proveedores empatan en score"

Esto es información, no un error. La confianza "baja" indica que necesitas más datos. Tres opciones: agrega un criterio diferenciador, ejecuta un PoC comparativo con ambos finalistas, o aplica la regla de la cápsula 06: ante empate, elige el más simple operativamente.

"Quiero cambiar los pesos según mi contexto"

Para eso está CRITERIA_WEIGHTS centralizado. Cambia los pesos, re-ejecuta --validate, y verifica que los escenarios sigan coherentes. Si un cambio de peso rompe un escenario, tienes un trade-off que documentar.


🏋️ Ejercicios post-proyecto

Ejercicio 1: Agregar criterio de vendor lock-in

Extiende el cuestionario con una pregunta sobre preocupación de portabilidad y un nuevo criterio en la matriz.

Pistas:

  • Agrega una entrada "lock_in" a CRITERIA_WEIGHTS.
  • Agrega scores de lock-in a cada VectorDBProvider.
  • Crea una nueva Question con opciones que mapeen a "lock_in".
Ver solución
# 1. Agregar criterio:
CRITERIA_WEIGHTS["lock_in"] = {"name": "Riesgo de vendor lock-in", "weight": 3}

# 2. Agregar scores a cada proveedor (en su dict "scores"):
# ChromaDB:  "lock_in": 1.0   (open source, sin lock-in)
# Pinecone:  "lock_in": 0.2   (API propietaria, sin self-hosted)
# Weaviate:  "lock_in": 0.8   (open source, API estándar)
# Qdrant:    "lock_in": 0.8   (open source, gRPC estándar)
# Milvus:    "lock_in": 0.8   (open source, API estándar)

# 3. Agregar pregunta a QUESTIONS:
Question(
    id="lock_in",
    text="¿Qué tan importante es evitar vendor lock-in?",
    context="Lock-in = dificultad de migrar a otro proveedor en el futuro.",
    options=[
        QuestionOption("No me preocupa - priorizo velocidad",       {"lock_in": 0.1}),
        QuestionOption("Moderada - quiero opciones pero no urgente", {"lock_in": 0.5}),
        QuestionOption("Alta - necesito poder migrar sin reescribir", {"lock_in": 1.0}),
    ],
)

# 4. Re-ejecutar --validate para verificar coherencia

Ejercicio 2: Exportar resultado a Markdown

Crea una función que convierta el QuestionnaireResult en un documento Markdown listo para compartir en un PR o documento de decisión.

Pistas:

  • La función recibe un QuestionnaireResult y retorna un string Markdown.
  • Usa tablas Markdown para el ranking y el detalle de scoring.
  • Incluye sección de supuestos y fecha de vigencia.
Ver solución
from datetime import date

def export_to_markdown(result: QuestionnaireResult, project_name: str = "Proyecto") -> str:
    """Exporta el resultado del cuestionario a Markdown."""
    lines = []
    lines.append(f"# Decisión de Vector Database — {project_name}")
    lines.append(f"\n**Fecha:** {date.today().isoformat()}")
    lines.append(f"**Confianza:** {result.confidence}")
    lines.append(f"**Vigencia sugerida:** 3 meses o hasta cambio de escala 2x")

    lines.append("\n## Ranking\n")
    lines.append("| # | Proveedor | Score | Ajuste |")
    lines.append("|---|-----------|------:|--------|")
    for rank, r in enumerate(result.rankings, 1):
        fit = "excelente" if r.normalized_score >= 80 else ("bueno" if r.normalized_score >= 65 else "viable")
        lines.append(f"| {rank} | {r.provider_name} | {r.normalized_score:.1f}% | {fit} |")

    prov = PROVIDERS[result.primary.provider_name]
    lines.append(f"\n## Recomendación: {result.primary.provider_name}\n")
    lines.append(f"**Costo típico:** {prov.typical_cost_range}\n")
    lines.append("### Fortalezas\n")
    for s in result.primary.strengths:
        lines.append(f"- {s}")
    lines.append("\n### Riesgos\n")
    for risk in prov.risks:
        lines.append(f"- {risk}")

    lines.append(f"\n## Alternativa: {result.alternative.provider_name}\n")
    lines.append(f"Score: {result.alternative.normalized_score:.1f}%\n")
    lines.append("Considerar si:")
    for c in _get_switch_conditions(result.primary.provider_name, result.alternative.provider_name):
        lines.append(f"- {c}")

    lines.append("\n## Detalle de scoring\n")
    lines.append("| Criterio | Peso | P1 | P2 |")
    lines.append("|----------|-----:|---:|---:|")
    for criterion, config in CRITERIA_WEIGHTS.items():
        p1 = result.primary.criteria_breakdown[criterion]["provider_score"]
        p2 = result.alternative.criteria_breakdown[criterion]["provider_score"]
        lines.append(f"| {config['name']} | {config['weight']} | {p1:.1f} | {p2:.1f} |")

    if result.warnings:
        lines.append("\n## Alertas\n")
        for w in result.warnings:
            lines.append(f"- ⚠️ {w}")

    lines.append("\n---\n*Generado con Vector DB Decision Questionnaire — Módulo 6*")
    return "\n".join(lines)

# Uso:
# md = export_to_markdown(result, "Mi Proyecto RAG")
# with open("decision-vectordb.md", "w") as f:
#     f.write(md)

Ejercicio 3: Análisis de sensibilidad de pesos

Crea una función que varíe cada peso de CRITERIA_WEIGHTS (±1) y muestre cuáles cambian la recomendación — identificando los criterios más sensibles.

Pistas:

  • Clona CRITERIA_WEIGHTS, modifica un peso a la vez, re-calcula scores.
  • Compara si el primary cambia con cada variación.
  • Los criterios que cambian la recomendación son los más sensibles.
Ver solución
import copy

def sensitivity_analysis(answers: dict[str, int]) -> str:
    """Identifica qué pesos son más sensibles al resultado."""
    global CRITERIA_WEIGHTS
    base_result = evaluate_all_providers(answers)
    base_primary = base_result.primary.provider_name
    original_weights = copy.deepcopy(CRITERIA_WEIGHTS)
    lines = []
    lines.append("=" * 65)
    lines.append(f"  ANÁLISIS DE SENSIBILIDAD — Base: {base_primary} ({base_result.primary.normalized_score:.1f}%)")
    lines.append("=" * 65)

    sensitive = []
    for criterion, config in original_weights.items():
        original_w = config["weight"]
        lines.append(f"\n📊 {config['name']} (peso base: {original_w})")
        lines.append(f"   {'Peso':<8} {'Recomendación':<15} {'Score':>7} {'Cambió?':>8}")
        lines.append("   " + "-" * 42)

        changed = False
        for delta in [-2, -1, 0, 1, 2]:
            new_w = max(1, min(5, original_w + delta))
            CRITERIA_WEIGHTS[criterion]["weight"] = new_w
            varied = evaluate_all_providers(answers)
            is_diff = varied.primary.provider_name != base_primary
            if is_diff:
                changed = True
            marker = "⚠️ SÍ" if is_diff else "  no"
            current = " ←" if delta == 0 else ""
            lines.append(f"   {new_w:<8} {varied.primary.provider_name:<15} "
                         f"{varied.primary.normalized_score:>6.1f}% {marker:>8}{current}")
        CRITERIA_WEIGHTS[criterion]["weight"] = original_w
        if changed:
            sensitive.append(config["name"])

    CRITERIA_WEIGHTS = original_weights

    if sensitive:
        lines.append(f"\n⚠️ Criterios sensibles:")
        for c in sensitive:
            lines.append(f"   • {c}")
        lines.append("Estos pesos debes estimar con mayor cuidado.")
    else:
        lines.append("\n✅ Decisión robusta — ningún peso individual cambia la recomendación.")
    return "\n".join(lines)

# Uso:
# print(sensitivity_analysis(answers))

El output mostrará qué campos marcados con "SÍ" son los inputs más sensibles — los que debes estimar con mayor cuidado.


✅ Checklist de completitud

Estructura del código:

  • CRITERIA_WEIGHTS tiene 8 criterios con pesos de 1 a 5.
  • PROVIDERS cubre 5 proveedores con scores por criterio.
  • QUESTIONS tiene 8 preguntas con opciones cerradas.
  • Cada opción mapea a criterios de la matriz.

Motor de scoring:

  • score_provider() aplica sum(peso * score) normalizada a 0-100.
  • evaluate_all_providers() rankea proveedores y calcula confianza.
  • _detect_warnings() identifica combinaciones contradictorias.

Calidad de la decisión:

  • Justificación explica fortalezas (por qué sí) y riesgos (cuidado con).
  • La alternativa incluye condiciones de cuándo preferirla.
  • Detalle de scoring por criterio visible en el reporte.

Validación:

  • 3 escenarios ejecutados (MVP, SaaS, Enterprise).
  • Discrepancias entre esperado y actual explicadas.
  • --validate produce output legible y coherente.

Output:

  • Reporte legible sin contexto adicional.
  • Ranking completo de 5 proveedores visible.
  • Próximos pasos ajustados al nivel de confianza.

Resumen

  • Construiste un cuestionario interactivo que transforma respuestas en scoring ponderado sobre 5 proveedores de vector database.
  • Cada pregunta tiene opciones cerradas que mapean directamente a criterios de la matriz del módulo.
  • El motor aplica la fórmula score_total = sum(peso * score) con pesos ajustables y normalización a 0-100.
  • El reporte produce recomendación + alternativa con fortalezas, riesgos, detalle de scoring y condiciones de cambio.
  • Validaste con 3 escenarios (MVP, SaaS, Enterprise) que cubren combinaciones representativas de requisitos.
  • El nivel de confianza (alta/media/baja) indica cuándo la decisión es clara vs. cuándo necesitas más análisis.
  • Los pesos centralizados permiten adaptar la herramienta sin modificar la lógica del motor.
  • Este proyecto cierra el Módulo 6 y conecta con el Módulo 7 (consideraciones de producción).

📚 Recursos adicionales


Tiempo estimado: 35-45 minutos
Siguiente módulo: ../../module-07-production-considerations/es/01-introduccion-modulo.md