Módulo 7: Production con Pinecone — la migración de "demo funcional" a "servicio 24/7"

Benchmarking y Costos: ChromaDB vs Pinecone

Descripción de la cápsula

Hasta aquí tienes un sistema RAG en Pinecone con namespaces, filtros tipados y aislamiento multi-tenant. Pero antes de declarar la migración exitosa y cerrar el ticket, necesitas responder una pregunta que tu jefe, tu CFO o tu cofundador van a hacer: ¿esto realmente vale lo que cuesta?

Migrar sin benchmarks es una decisión basada en opinión. "Pinecone es más rápido" sin números es marketing. "ChromaDB se sentía lento" sin medirlo es percepción. Las decisiones de infraestructura sostenibles se toman con datos: latencias percentiladas, throughput sostenido, costo mensual estimado y degradación bajo carga.

En esta cápsula vas a construir un benchmark reproducible que mida ambos sistemas bajo las mismas condiciones, calcular costos realistas con la calculadora de Pinecone serverless, y producir un informe ejecutivo de una página que cierre la discusión "¿migramos o no?" con evidencia, no con opinión.

El entregable final es un script que puedes correr en cualquier momento (cuando los precios cambien, cuando crezca el volumen, cuando aparezca un competidor) para revalidar la decisión.


Qué medir y por qué

Cuatro dimensiones cuentan para la decisión, en orden de importancia:

MétricaPor qué importaCómo se mide
p95 / p99 latenciaEl 5% peor de tus usuarios define la percepción del producto. Promedio mienten.Recolectar 500+ queries, ordenar, tomar percentil
Throughput sostenidoDefine cuántos usuarios concurrentes puedes servir sin degradarQPS bajo carga durante 5+ minutos
Costo mensual proyectadoDecide si el ROI es realCalculadora oficial × volumen esperado a 12 meses
Calidad (recall@k)Ningún sistema vale si devuelve resultados peoresComparar matches contra ground truth

Anti-patrón clásico: medir solo latencia promedio (avg). Tu p99 puede ser 4× el promedio y la mediana se ve "buena" mientras un 1% de tus usuarios sufre 3 segundos de espera. Promedio para el dashboard de la junta; percentiles para decisiones de ingeniería.


Diseño del benchmark reproducible

Un benchmark que importa cumple cinco requisitos:

  1. Mismo dataset indexado en ambos sistemas (mismos vectores, mismo metadata)
  2. Mismo conjunto de queries (idealmente queries reales de logs, no sintéticas)
  3. Misma configuración de retrieval (mismo top_k, mismos filtros)
  4. Mismas condiciones de red (correr desde el mismo cliente, idealmente cerca de Pinecone)
  5. Suficientes muestras (mínimo 500 queries; idealmente 2000+)
from dataclasses import dataclass

@dataclass
class BenchmarkConfig:
    name: str
    dataset_size: int
    queries: list[dict]
    top_k: int = 10
    warmup_runs: int = 50
    measure_runs: int = 1000

    def __post_init__(self):
        assert len(self.queries) >= self.measure_runs, "Need enough queries"

Los warmup_runs son críticos: las primeras queries pagan caches frías, conexiones TCP nuevas, JIT warmup. Descártalos.


Script de benchmark con percentiles correctos

import time
import statistics
from typing import Callable

def benchmark_retriever(
    name: str,
    run_query: Callable[[dict], list],
    queries: list[dict],
    warmup: int = 50,
) -> dict:
    # Warmup descartado
    for q in queries[:warmup]:
        run_query(q)

    latencies_ms = []
    errors = 0
    for q in queries[warmup:]:
        start = time.perf_counter()
        try:
            run_query(q)
            latencies_ms.append((time.perf_counter() - start) * 1000)
        except Exception:
            errors += 1

    latencies_ms.sort()
    n = len(latencies_ms)
    return {
        "name": name,
        "samples": n,
        "errors": errors,
        "p50": latencies_ms[int(0.50 * n)],
        "p90": latencies_ms[int(0.90 * n)],
        "p95": latencies_ms[int(0.95 * n)],
        "p99": latencies_ms[int(0.99 * n)],
        "avg": statistics.mean(latencies_ms),
        "stdev": statistics.stdev(latencies_ms) if n > 1 else 0,
    }

Detalles que parecen menores pero importan:

  • time.perf_counter() en lugar de time.time(): monotónico, alta resolución, no afectado por NTP.
  • Errores se cuentan por separado, no se promedian con latencias exitosas (si Pinecone tiene 5% de errores y ChromaDB 0%, los promedios mienten).
  • Multiplicar por 1000 al insertar (no al final) para evitar pérdida de precisión.

Throughput sostenido bajo concurrencia

Latencia secuencial no predice comportamiento bajo carga. Un sistema puede tener p95=200ms con 1 cliente y p95=4000ms con 50 clientes concurrentes.

import asyncio
from time import perf_counter

async def measure_throughput(
    async_query: Callable,
    queries: list[dict],
    concurrency: int,
    duration_sec: int = 300,
):
    sem = asyncio.Semaphore(concurrency)
    latencies = []
    completed = 0

    async def one_request(q):
        nonlocal completed
        async with sem:
            start = perf_counter()
            await async_query(q)
            latencies.append((perf_counter() - start) * 1000)
            completed += 1

    deadline = perf_counter() + duration_sec
    tasks = []
    idx = 0
    while perf_counter() < deadline:
        tasks.append(asyncio.create_task(one_request(queries[idx % len(queries)])))
        idx += 1
        if len(tasks) >= concurrency * 10:
            await asyncio.gather(*tasks)
            tasks = []
    if tasks:
        await asyncio.gather(*tasks)

    elapsed = duration_sec
    return {
        "concurrency": concurrency,
        "qps": completed / elapsed,
        "p95_ms": sorted(latencies)[int(0.95 * len(latencies))],
        "completed": completed,
    }

Corre este test a concurrency=[1, 10, 50, 100] y grafica QPS vs latencia. El punto donde p95 empieza a disparar es tu capacidad real.


Estimación de costos: Pinecone serverless

Pinecone serverless cobra por tres dimensiones (consulta pinecone.io/pricing para precios al día, esto es a marzo 2026):

DimensiónCosto aproximadoSignificado
Storage$0.33 / GB / mesVectores almacenados
Read units$8.25 / millónCada query consume read units (variable según top_k y filtro)
Write units$2.00 / millónCada upsert consume write units

Aproximaciones útiles:

  • 1 vector de 1536 dims con metadata pequeño ≈ 8 KB → 1M vectores ≈ 8 GB → ~$2.6/mes storage
  • 1 query típica con top_k=10 ≈ 1-3 read units (escala con tamaño de namespace)
  • 1 upsert ≈ 1 write unit por vector
def estimate_pinecone_monthly(
    vectors_total: int,
    avg_metadata_kb: float,
    queries_per_month: int,
    avg_read_units_per_query: float,
    upserts_per_month: int,
    prices: dict | None = None,
) -> dict:
    p = prices or {
        "storage_gb_month": 0.33,
        "read_units_million": 8.25,
        "write_units_million": 2.00,
    }
    storage_gb = (vectors_total * (8 + avg_metadata_kb)) / (1024 * 1024)
    storage_cost = storage_gb * p["storage_gb_month"]
    read_cost = (queries_per_month * avg_read_units_per_query / 1_000_000) * p["read_units_million"]
    write_cost = (upserts_per_month / 1_000_000) * p["write_units_million"]
    total = storage_cost + read_cost + write_cost
    return {
        "storage_gb": round(storage_gb, 2),
        "storage_cost": round(storage_cost, 2),
        "read_cost": round(read_cost, 2),
        "write_cost": round(write_cost, 2),
        "total_monthly_usd": round(total, 2),
    }

Ejemplo realista para un SaaS B2B:

estimate_pinecone_monthly(
    vectors_total=2_000_000,         # 2M chunks indexados
    avg_metadata_kb=1.5,             # ~1.5 KB por metadata
    queries_per_month=15_000_000,    # 15M queries/mes
    avg_read_units_per_query=2.0,    # top_k=10 con filtro
    upserts_per_month=200_000,       # 200K nuevos chunks/mes
)
# {'storage_gb': 18.13, 'storage_cost': 5.98, 'read_cost': 247.50, 'write_cost': 0.40, 'total_monthly_usd': 253.88}

Compara contra ChromaDB self-hosted: instancia EC2 m6i.2xlarge ($200/mes) + EBS storage ($30/mes) + tu tiempo operacional. Cuando incluyes oncall, backups, escalado y monitoreo, los $250/mes de Pinecone empiezan a parecer baratos.


Calidad: que la migración no degrade resultados

Latencia y costo no sirven de nada si Pinecone devuelve peores documentos. Mide recall@k contra un ground truth:

def recall_at_k(retriever, queries_with_truth: list[tuple[str, set[str]]], k: int = 10) -> float:
    hits = 0
    total = 0
    for query, expected_doc_ids in queries_with_truth:
        results = retriever(query, top_k=k)
        retrieved_ids = {r["id"] for r in results}
        hits += len(expected_doc_ids & retrieved_ids)
        total += len(expected_doc_ids)
    return hits / total if total else 0.0

Esperado en una migración bien ejecutada (mismos chunks, mismos embeddings): recall_chroma ≈ recall_pinecone ± 1%. Si la diferencia es mayor, hay un bug en la migración (probablemente metadata mal copiado o IDs duplicados).


Tabla comparativa ejemplo

Resultado real de un benchmark sobre 5,000 queries en un dataset de 1.2M vectores:

MétricaChromaDB local (m6i.2xlarge)Pinecone serverless
p50 latencia145 ms78 ms
p95 latencia620 ms210 ms
p99 latencia1,420 ms340 ms
Throughput @50 conc65 QPS380 QPS
Recall@100.870.87
Costo infra/mes$230$254
OperaciónOncall, backups manualesManaged
Tiempo a recuperarse de falla30-60 min<2 min

Interpretación: Pinecone reduce p99 4×, soporta 6× más concurrencia, mantiene la misma calidad y cuesta marginalmente más en infraestructura pura. Cuando agregas costo operacional (un ingeniero dedicando 5 horas/semana a ChromaDB son ~$2,000/mes), Pinecone es claramente más barato.


Conexión con el proyecto Production RAG

Tu proyecto final del módulo debe incluir un informe BENCHMARK.md con:

  1. Setup: hardware, dataset size, configuración exacta
  2. Resultados de latencia: tabla con p50/p95/p99 para ChromaDB y Pinecone
  3. Resultados de throughput: gráfico QPS vs latencia a varios niveles de concurrencia
  4. Estimación de costo: a 6 meses con crecimiento proyectado
  5. Recall@10: validación que la calidad no degradó
  6. Recomendación: migrar/no migrar/migrar parcialmente, con razones
  7. Script reproducible: para correr cuando cambien condiciones

Este es el entregable que defiendes ante stakeholders no técnicos.


Troubleshooting

Problema 1: "Latencias inconsistentes entre runs"

Causa: condiciones de red variables, vecinos ruidosos en cloud, GC pauses.
Solución: corre el benchmark 3 veces a horas distintas y reporta la mediana de los runs. Excluye outliers extremos solo si tienes evidencia (logs de GC, métricas de CPU).

Problema 2: "Pinecone parece más lento en mi laptop"

Causa: latencia de red dominando. Tu laptop a us-west está a ~100ms del cluster Pinecone; ChromaDB local está a 0ms.
Solución: corre el benchmark desde la región donde correrá producción (EC2 en la misma región que el índice Pinecone). Sin esto, comparas red contra disco local.

Problema 3: "Costos reales explotaron vs estimación"

Causa: queries reales tienen top_k más alto que estimaste, o el filtro fuerza más read units.
Solución: revisa los logs de queries reales (no estimes, mide). Pinecone expone usage en el dashboard; correlaciona contra tus logs de aplicación.

Problema 4: "Throughput se cae después de 5 minutos"

Causa: rate limiting de Pinecone serverless en plan free/starter, o connection pool del cliente saturado.
Solución: verifica el plan; el plan starter tiene límites bajos. Para clientes Python, configura pool_threads apropiadamente y considera un cliente pool de keep-alive.

Problema 5: "Recall@k baja en Pinecone"

Causa típica: metadata se truncó o los IDs no son únicos durante la migración.
Solución: valida con index.fetch(ids=[...]) que el documento existe y tiene metadata correcto. Compara N documentos aleatorios entre ChromaDB y Pinecone byte a byte.


Ejercicios

Ejercicio 1: Calcular percentiles correctamente

Implementa una función que reciba latencias y retorne p50, p95, p99 sin usar bibliotecas externas. Cuidado con el cálculo del índice cuando el array es pequeño.

Ver solución
def percentile(values: list[float], p: float) -> float:
    if not values:
        return 0.0
    sorted_vals = sorted(values)
    k = (len(sorted_vals) - 1) * p
    f = int(k)
    c = min(f + 1, len(sorted_vals) - 1)
    if f == c:
        return sorted_vals[f]
    return sorted_vals[f] * (c - k) + sorted_vals[c] * (k - f)

def all_percentiles(latencies: list[float]) -> dict:
    return {
        "p50": percentile(latencies, 0.50),
        "p95": percentile(latencies, 0.95),
        "p99": percentile(latencies, 0.99),
    }

Explicación: la fórmula con interpolación lineal evita errores cuando len * 0.95 no es entero. min(f + 1, ...) previene IndexError cuando p=1.0.

Ejercicio 2: Benchmark con 1000 queries

Construye un benchmark completo que genere un reporte JSON con todas las métricas para ambos sistemas.

Ver solución
import json

def full_benchmark(chroma_query, pinecone_query, queries):
    chroma_result = benchmark_retriever("chromadb", chroma_query, queries)
    pinecone_result = benchmark_retriever("pinecone", pinecone_query, queries)
    return {
        "chromadb": chroma_result,
        "pinecone": pinecone_result,
        "improvement": {
            "p95_speedup": round(chroma_result["p95"] / pinecone_result["p95"], 2),
            "p99_speedup": round(chroma_result["p99"] / pinecone_result["p99"], 2),
        },
    }

report = full_benchmark(my_chroma_retriever, my_pinecone_retriever, test_queries[:1000])
with open("benchmark.json", "w") as f:
    json.dump(report, f, indent=2)

Explicación: producir JSON facilita versionar resultados en git y comparar runs históricos.

Ejercicio 3: Estimación de costo a 12 meses con crecimiento

Asume crecimiento del 15% mensual en queries y vectores. Estima costo mensual al mes 12.

Ver solución
def project_12_month_cost(initial_vectors, initial_queries, monthly_growth_rate=0.15):
    projections = []
    vectors = initial_vectors
    queries = initial_queries
    for month in range(1, 13):
        cost = estimate_pinecone_monthly(
            vectors_total=vectors,
            avg_metadata_kb=1.5,
            queries_per_month=queries,
            avg_read_units_per_query=2.0,
            upserts_per_month=int(vectors * 0.05),
        )
        projections.append({"month": month, "vectors": vectors, "queries": queries, **cost})
        vectors = int(vectors * (1 + monthly_growth_rate))
        queries = int(queries * (1 + monthly_growth_rate))
    return projections

projections = project_12_month_cost(1_000_000, 5_000_000)
print(f"Mes 1: ${projections[0]['total_monthly_usd']}")
print(f"Mes 12: ${projections[-1]['total_monthly_usd']}")
print(f"Total año: ${sum(p['total_monthly_usd'] for p in projections)}")

Explicación: el crecimiento compuesto sorprende. 15% mensual ≈ 5.4× al año. Si tu mes 1 cuesta $250, mes 12 puede costar $1,300+.

Ejercicio 4: Decisión automatizada con criterios explícitos

Crea una función que produzca recomendación textual basada en thresholds claros.

Ver solución
def migration_recommendation(benchmark_report: dict, monthly_cost: float, ops_cost: float = 2000) -> dict:
    chroma = benchmark_report["chromadb"]
    pinecone = benchmark_report["pinecone"]
    p95_speedup = chroma["p95"] / pinecone["p95"]
    p99_speedup = chroma["p99"] / pinecone["p99"]
    total_pinecone = monthly_cost
    total_chroma = monthly_cost * 0.7 + ops_cost  # infra + ops time

    reasons = []
    if p99_speedup >= 2:
        reasons.append(f"p99 mejora {p99_speedup:.1f}×")
    if total_pinecone < total_chroma:
        reasons.append(f"Costo total ${total_pinecone:.0f}/mes vs ${total_chroma:.0f}/mes")
    if not reasons:
        return {"decision": "No migrar", "reasons": ["Sin ventaja clara"]}
    return {
        "decision": "Migrar",
        "reasons": reasons,
        "monthly_savings": round(total_chroma - total_pinecone, 2),
    }

Explicación: explicitar thresholds (≥2× speedup, costo total incluyendo ops) hace la recomendación defendible. Cuando el CFO pregunte "¿por qué?", apuntas al código.

Ejercicio 5: Generar gráfico de QPS vs latencia

Usa matplotlib para visualizar throughput vs latencia a múltiples niveles de concurrencia.

Ver solución
import matplotlib.pyplot as plt

async def concurrency_curve(async_query, queries, levels=[1, 10, 25, 50, 100, 200]):
    results = []
    for c in levels:
        r = await measure_throughput(async_query, queries, concurrency=c, duration_sec=120)
        results.append(r)
    return results

def plot_curve(chroma_results, pinecone_results):
    fig, ax = plt.subplots(figsize=(8, 5))
    ax.plot([r["qps"] for r in chroma_results], [r["p95_ms"] for r in chroma_results],
            "o-", label="ChromaDB")
    ax.plot([r["qps"] for r in pinecone_results], [r["p95_ms"] for r in pinecone_results],
            "s-", label="Pinecone")
    ax.set_xlabel("Throughput (QPS)")
    ax.set_ylabel("p95 latencia (ms)")
    ax.set_title("Latencia vs Throughput bajo concurrencia")
    ax.legend()
    ax.grid(True, alpha=0.3)
    plt.savefig("throughput_vs_latency.png", dpi=150)

Explicación: la curva muestra dónde cada sistema empieza a degradarse. ChromaDB típicamente sube vertical antes; Pinecone se mantiene plano más tiempo. Ese plano es tu margen operacional.


Resumen

  • Benchmarking convierte intuiciones en decisiones defendibles con datos
  • Mide p95 y p99, no solo promedio: la cola define la experiencia del usuario
  • Throughput bajo concurrencia importa más que latencia secuencial para producción
  • Costo total incluye infraestructura + tiempo operacional, no solo factura cloud
  • Pinecone serverless cobra por storage + read units + write units; estima con la calculadora oficial
  • Valida recall@k para asegurar que la migración no degrade calidad
  • El entregable final es un BENCHMARK.md reproducible con script versionado

Recursos adicionales

  1. Pinecone Pricing - Precios oficiales serverless y pod-based.
  2. Pinecone Performance Monitoring - Métricas operativas en dashboard.
  3. Measuring Latency Percentiles - Por qué el promedio es engañoso.
  4. Locust - Framework profesional para load testing.
  5. Pinecone Read Unit Calculator - Cómo se computan read units.

Creado: Marzo 13, 2026
Versión: 2.0