Módulo 7: Production con Pinecone — la migración de "demo funcional" a "servicio 24/7"
Cápsula 04: Migración ChromaDB → Pinecone — sin perder datos ni romper queries
Descripción de la cápsula
La migración real es donde la mayoría de proyectos se complica. No es "exportar de ChromaDB y meter en Pinecone" — eso falla 50% de las veces. La diferencia entre una migración suave y un incidente está en los detalles: validación de integridad, manejo de errores por batch, dual-write durante transición, rollback plan, y A/B testing antes del switchover total.
Esta cápsula te enseña la migración correcta: estrategia incremental con downtime mínimo, scripts robustos con retry exponencial, validación de conteos e integridad después de cada paso, y plan de rollback si algo sale mal en producción.
Al finalizar esta cápsula serás capaz de:
- ✅ Implementar script de migración por batches con retry y logging
- ✅ Validar integridad: conteos, metadata preservada, embeddings consistentes
- ✅ Diseñar estrategia incremental (no big-bang) con dual-write durante transición
- ✅ Implementar rollback plan con feature flag
- ✅ Validar calidad de retrieval post-migración con eval set
- ✅ Anticipar los cuatro errores más comunes: payload size, metadata types, namespace incorrecto, dimensión mismatch
Tiempo estimado: 35-40 minutos
Estrategia: incremental, no big-bang
BIG-BANG (peligroso)
Día 1: Stop app
Día 1-3: Migrar todo a Pinecone
Día 3: Switch app a Pinecone
Día 3+: Esperar que funcione
Si algo falla: rollback toma horas, downtime extendido
INCREMENTAL (recomendado)
Semana 1: Setup Pinecone + script de migración + tests
Semana 2: Dual-write (escribir en ChromaDB Y Pinecone para cada doc nuevo)
Semana 3: Backfill (migrar histórico de ChromaDB a Pinecone)
Semana 4: Dual-read con A/B test (query a ambos, comparar)
Semana 5: Switch primario a Pinecone, ChromaDB como fallback
Semana 6: Decommission ChromaDB
Si algo falla: rollback en minutos via feature flag
La migración incremental es la regla. Big-bang solo se justifica para corpus chico (<100K docs) o sistemas con downtime planificado.
Script base: export desde ChromaDB
# migration_script.py
from typing import Iterable
import chromadb
from chromadb.utils import embedding_functions
import os
def export_from_chromadb(collection_name: str, batch_size: int = 1000) -> Iterable[dict]:
"""
Exporta documentos en batches para no cargar todo en RAM.
Yield batches de dicts con id, embedding, metadata.
"""
chroma_client = chromadb.PersistentClient(path="./chroma_db")
collection = chroma_client.get_collection(collection_name)
total = collection.count()
print(f"Exportando {total} docs de '{collection_name}'")
offset = 0
while offset < total:
batch = collection.get(
limit=batch_size,
offset=offset,
include=["embeddings", "metadatas", "documents"],
)
# Yield batch como lista de dicts
items = []
for i in range(len(batch["ids"])):
items.append({
"id": batch["ids"][i],
"embedding": batch["embeddings"][i],
"metadata": batch["metadatas"][i] or {},
"document": batch["documents"][i],
})
yield items
offset += batch_size
print(f" Exportados {min(offset, total)}/{total}")
Por qué batches: colecciones grandes (5M+ docs) no caben en RAM. Batch de 1000 mantiene RAM ~500MB.
Upsert a Pinecone con retry
import time
from pinecone import Pinecone
def upsert_to_pinecone(
index,
items: list,
namespace: str = "",
max_retries: int = 3,
) -> int:
"""
Upsert batch a Pinecone con retry exponencial.
Retorna count de items insertados exitosamente.
"""
# Construir vectors en formato Pinecone
vectors = []
for item in items:
# Pinecone metadata debe ser serializable y plano
metadata = clean_metadata_for_pinecone(item["metadata"])
# Agregar contenido del documento como metadata para retrieval posterior
metadata["content"] = item["document"][:40000] # Pinecone limit
vectors.append({
"id": item["id"],
"values": item["embedding"],
"metadata": metadata,
})
# Upsert con retry exponencial
for attempt in range(max_retries):
try:
response = index.upsert(vectors=vectors, namespace=namespace)
return response.upserted_count
except Exception as e:
if attempt == max_retries - 1:
# Último intento, re-raise
raise
wait_time = 2 ** attempt # 1, 2, 4 segundos
print(f" Error en attempt {attempt + 1}: {e}. Retry en {wait_time}s...")
time.sleep(wait_time)
def clean_metadata_for_pinecone(metadata: dict) -> dict:
"""
Pinecone restricts metadata to: strings, numbers, booleans, lists of strings.
No nested dicts or other types.
"""
cleaned = {}
for key, value in metadata.items():
if isinstance(value, (str, int, float, bool)):
cleaned[key] = value
elif isinstance(value, list):
# Convertir todo a strings
cleaned[key] = [str(v) for v in value]
elif value is None:
# Skip None values
continue
elif isinstance(value, dict):
# Flatten dict: {a: {b: 1}} → {a_b: 1}
for sub_key, sub_value in value.items():
if isinstance(sub_value, (str, int, float, bool)):
cleaned[f"{key}_{sub_key}"] = sub_value
else:
# Convert otros tipos a string
cleaned[key] = str(value)
return cleaned
Detalles importantes:
- Pinecone tiene límite de 40,000 caracteres por metadata field. Truncar
content. - Metadata debe ser plana (sin dicts anidados). Flatten si es necesario.
- Solo tipos primitivos en metadata: str, int, float, bool, list[str].
Validación de integridad
Después de migrar, validar que NO se perdió nada:
def validate_migration(
chroma_collection,
pinecone_index,
namespace: str = "",
) -> dict:
"""
Validaciones post-migración:
1. Conteos coinciden
2. Sample de IDs aleatorios existen en ambos
3. Metadata preservada
"""
# 1. Conteos
chroma_count = chroma_collection.count()
pinecone_stats = pinecone_index.describe_index_stats()
pinecone_count = pinecone_stats["namespaces"].get(namespace, {}).get("vector_count", 0)
print(f"Counts: ChromaDB={chroma_count}, Pinecone={pinecone_count}")
if chroma_count != pinecone_count:
return {
"valid": False,
"error": f"Count mismatch: chroma={chroma_count} != pinecone={pinecone_count}",
}
# 2. Sample de IDs aleatorios
import random
chroma_data = chroma_collection.get(limit=20) # primeros 20 como sample
sample_ids = random.sample(chroma_data["ids"], min(20, len(chroma_data["ids"])))
pinecone_fetched = pinecone_index.fetch(ids=sample_ids, namespace=namespace)
missing_ids = [
sid for sid in sample_ids
if sid not in pinecone_fetched.get("vectors", {})
]
if missing_ids:
return {
"valid": False,
"error": f"Missing IDs in Pinecone: {missing_ids[:5]}",
}
# 3. Validar metadata preservada (sample)
for sid in sample_ids[:5]:
chroma_meta = chroma_collection.get(ids=[sid], include=["metadatas"])["metadatas"][0]
pinecone_meta = pinecone_fetched["vectors"][sid]["metadata"]
# Comparar campos críticos
for critical_field in ["workspace_id", "doc_id", "type"]:
if chroma_meta.get(critical_field) != pinecone_meta.get(critical_field):
return {
"valid": False,
"error": f"Metadata mismatch for {sid} field {critical_field}",
}
return {
"valid": True,
"chroma_count": chroma_count,
"pinecone_count": pinecone_count,
"sample_validated": len(sample_ids),
}
# Uso
result = validate_migration(chroma_collection, pinecone_index, namespace="acme_corp")
if result["valid"]:
print("✅ Migración validada")
else:
print(f"❌ Migración FAILED: {result['error']}")
Validación de calidad: eval set comparativo
Conteos OK no es suficiente. Validar que el retrieval da resultados similares:
def validate_quality_post_migration(
chroma_collection,
pinecone_index,
eval_set: list,
threshold_recall: float = 0.85,
):
"""
Compara recall del retrieval entre ChromaDB y Pinecone.
Si Pinecone tiene recall significativamente menor, la migración tiene problema.
"""
chroma_recalls = []
pinecone_recalls = []
for item in eval_set:
# ChromaDB query
chroma_results = chroma_collection.query(
query_texts=[item["query"]],
n_results=5,
where={"workspace_id": item["workspace_id"]},
)
chroma_top_5 = set(chroma_results["ids"][0])
# Pinecone query
from openai import OpenAI
client = OpenAI()
query_emb = client.embeddings.create(
input=item["query"],
model="text-embedding-3-small",
).data[0].embedding
pinecone_results = pinecone_index.query(
vector=query_emb,
top_k=5,
namespace=item["workspace_id"],
include_metadata=True,
)
pinecone_top_5 = set(m["id"] for m in pinecone_results["matches"])
# Recall vs ground truth
relevant = set(item["expected_doc_ids"])
chroma_recall = len(chroma_top_5 & relevant) / max(len(relevant), 1)
pinecone_recall = len(pinecone_top_5 & relevant) / max(len(relevant), 1)
chroma_recalls.append(chroma_recall)
pinecone_recalls.append(pinecone_recall)
avg_chroma = sum(chroma_recalls) / len(chroma_recalls)
avg_pinecone = sum(pinecone_recalls) / len(pinecone_recalls)
delta = avg_pinecone - avg_chroma
print(f"Recall ChromaDB: {avg_chroma:.2%}")
print(f"Recall Pinecone: {avg_pinecone:.2%}")
print(f"Delta: {delta:+.2%}")
if avg_pinecone < threshold_recall:
return {
"valid": False,
"error": f"Pinecone recall {avg_pinecone:.2%} < threshold {threshold_recall:.2%}",
}
if delta < -0.03: # más de 3% peor
return {
"valid": False,
"error": f"Pinecone recall {delta:+.2%} peor que ChromaDB",
}
return {"valid": True, "chroma_recall": avg_chroma, "pinecone_recall": avg_pinecone}
Estrategia incremental con feature flag
Durante la transición, queremos poder switch instantáneo entre ChromaDB y Pinecone:
# retrieval.py
import os
VECTOR_DB_BACKEND = os.getenv("VECTOR_DB_BACKEND", "chromadb") # chromadb | pinecone | dual
def retrieve(query: str, tenant: TenantContext, n_results: int = 5):
"""Retrieval que respeta el feature flag VECTOR_DB_BACKEND."""
if VECTOR_DB_BACKEND == "chromadb":
return retrieve_chromadb(query, tenant, n_results)
elif VECTOR_DB_BACKEND == "pinecone":
return retrieve_pinecone(query, tenant, n_results)
elif VECTOR_DB_BACKEND == "dual":
# Para A/B testing: ejecutar ambos, devolver chromadb pero loggear ambos
chroma_results = retrieve_chromadb(query, tenant, n_results)
try:
pinecone_results = retrieve_pinecone(query, tenant, n_results)
log_comparison(query, chroma_results, pinecone_results)
except Exception as e:
print(f"Pinecone failed (logging only): {e}")
return chroma_results
else:
raise ValueError(f"Unknown backend: {VECTOR_DB_BACKEND}")
Plan de rollout:
- Deploy con
VECTOR_DB_BACKEND=chromadb(sin cambio). - Configurar
VECTOR_DB_BACKEND=dualen producción → genera logs comparativos. - Si después de 1 semana los logs muestran resultados consistentes, cambiar a
pinecone. - Si algo está mal, rollback a
chromadbcon cambio de env var (5 minutos, sin redeploy).
Trampas y errores comunes
Trampa 1: payload size demasiado grande
El error: batch de 5000 vectors con embeddings de 3072 dim cada uno.
Síntoma: RequestError: Request body too large. Pinecone tiene limit ~2MB por request.
Cómo prevenir: batch_size de 100-200 con embeddings 1536d. Si dim es mayor, batch_size más chico.
Trampa 2: metadata con tipos no soportados
El error:
metadata = {
"tags": ["a", "b"],
"config": {"nested": "dict"}, # ← Pinecone no soporta dict anidado
"score": np.float64(0.5), # ← Tipo numpy
}
Síntoma: ValidationError: metadata field "config" type not supported.
Cómo prevenir: clean_metadata_for_pinecone (visto arriba) flatten dicts y convierte tipos.
Trampa 3: dimensión mismatch silencioso
El error: ChromaDB tiene embeddings de 1536d. Índice Pinecone con dim=384 (de un test).
Síntoma: Vector dimension 1536 does not match index dimension 384.
Cómo prevenir: validar dimensión del primer batch antes de procesar todo.
def validate_dimensions(items, expected_dim):
if items and len(items[0]["embedding"]) != expected_dim:
raise ValueError(
f"Dimension mismatch: items={len(items[0]['embedding'])}, expected={expected_dim}"
)
Trampa 4: namespace incorrecto
El error: olvidar el namespace en el upsert. Todo termina en namespace default "".
Síntoma: queries que filtran por namespace devuelven vacío.
Cómo prevenir: namespace siempre obligatorio en wrappers:
def safe_upsert(index, vectors, workspace_id):
if not workspace_id:
raise ValueError("workspace_id obligatorio")
namespace = f"ws_{workspace_id}"
index.upsert(vectors=vectors, namespace=namespace)
Trampa 5: no rate-limit el script de migración
El error: script paraleliza upsert con 50 workers concurrentes.
Síntoma: Pinecone rate limits, 429 errors, migración falla a mitad.
Cómo prevenir: Pinecone Standard permite ~1000 upserts/segundo. Con batch de 100, son 10 batches/segundo. Más de eso requiere plan superior.
# Throttling
time.sleep(0.05) # 50ms entre batches → 20 batches/segundo
Trampa 6: corte abrupto de migración a mitad
El error: la VM corre fuera de memoria a mitad. Tienes 3M de 5M docs migrados.
Síntoma: estado mixto, no puedes saber qué falta.
Cómo prevenir: trackear progreso por offset en archivo. Si script falla, retomar desde último offset.
def migrate_with_checkpoint(checkpoint_file: str = "./migration_progress.txt"):
last_offset = 0
if os.path.exists(checkpoint_file):
with open(checkpoint_file) as f:
last_offset = int(f.read().strip())
print(f"Resuming from offset {last_offset}")
# ... migrate from last_offset onwards ...
# Save progress periodically
with open(checkpoint_file, "w") as f:
f.write(str(current_offset))
Ejercicio aplicado
Escenario: eres AI Engineer, tienes que migrar a Pinecone. Datos:
- ChromaDB con 3M chunks de 50 tenants
- Modelo: OpenAI text-embedding-3-small (1536 dim)
- Sistema en producción 24/7, no hay ventana de mantenimiento
- Equipo: 3 personas
- Plazo: 2 semanas
Tu trabajo:
- Diseña plan de migración incremental.
- Implementa pseudo-código del script principal.
- Define rollback plan.
Solución
1. Plan de migración (2 semanas)
Día 1-2: Setup + tests
- Crear índice Pinecone (cápsula 03)
- Implementar export_from_chromadb + upsert_to_pinecone
- Tests con corpus chico (10K docs en staging)
- Validar dimension/metric/calidad
Día 3-5: Backfill histórico (3M docs)
- Script de migración con checkpoint (retomable)
- Throttling para no romper rate limits
- Logging de cada batch + retry exponencial
- Tiempo estimado: 30-45 minutos puros, ~3-4 horas con throttling
Día 6-7: Validación post-backfill
- Conteos coinciden (ChromaDB vs Pinecone)
- Sample 100 IDs aleatorios, validar metadata preservada
- Eval set comparativo (recall debe estar dentro de 2% del baseline)
Día 8-9: Dual-write
- App escribe nuevos docs en AMBOS sistemas
- Logs detectan inconsistencias
- VECTOR_DB_BACKEND=chromadb (queries siguen ahí)
Día 10-12: Dual-read con A/B
- VECTOR_DB_BACKEND=dual
- Queries van a chromadb (primario) y pinecone (sombra)
- Logs comparan resultados
- Si delta de calidad <2%, OK para switch
Día 13: Switch primario
- VECTOR_DB_BACKEND=pinecone
- ChromaDB sigue como fallback en caso de error
- Monitoring extra durante 24-48 horas
Día 14: Decommission
- Si Pinecone estable por 48 horas, deprecate ChromaDB
- Backup final de ChromaDB para retain
- Cleanup de código legacy
2. Pseudo-código del script principal
# scripts/migrate_to_pinecone.py
import os
import time
from pathlib import Path
CHECKPOINT_FILE = Path("./migration_checkpoint.txt")
BATCH_SIZE = 100
THROTTLE_MS = 50
def migrate():
chroma_collection = get_chroma_collection()
pinecone_index = get_pinecone_index()
# Resume desde checkpoint si existe
last_offset = 0
if CHECKPOINT_FILE.exists():
last_offset = int(CHECKPOINT_FILE.read_text().strip())
print(f"Resuming from offset {last_offset}")
total = chroma_collection.count()
failed_batches = []
for batch_start in range(last_offset, total, BATCH_SIZE):
# Export
batch = chroma_collection.get(
limit=BATCH_SIZE,
offset=batch_start,
include=["embeddings", "metadatas", "documents"],
)
# Group by namespace (workspace_id)
by_namespace = {}
for i in range(len(batch["ids"])):
ws_id = batch["metadatas"][i].get("workspace_id", "default")
namespace = f"ws_{ws_id}"
by_namespace.setdefault(namespace, []).append({
"id": batch["ids"][i],
"values": batch["embeddings"][i],
"metadata": clean_metadata({**batch["metadatas"][i], "content": batch["documents"][i][:40000]}),
})
# Upsert por namespace
for namespace, vectors in by_namespace.items():
try:
upsert_with_retry(pinecone_index, vectors, namespace)
except Exception as e:
print(f"FAILED batch {batch_start} namespace {namespace}: {e}")
failed_batches.append((batch_start, namespace))
# Save checkpoint
CHECKPOINT_FILE.write_text(str(batch_start + BATCH_SIZE))
# Progress log
progress = (batch_start + BATCH_SIZE) / total * 100
print(f" [{progress:.1f}%] Migrados hasta offset {batch_start + BATCH_SIZE}")
# Throttle
time.sleep(THROTTLE_MS / 1000)
# Final report
print(f"\nMigración completada.")
print(f"Failed batches: {failed_batches}")
if failed_batches:
# Save list para retry manual
Path("./failed_batches.json").write_text(json.dumps(failed_batches))
if __name__ == "__main__":
migrate()
3. Rollback plan
Niveles de rollback (de menor a mayor severidad):
Nivel 1 — Rollback inmediato (5 minutos)
Si después del switch a Pinecone (día 13) detectamos problema:
# Cambio de env var en producción
export VECTOR_DB_BACKEND=chromadb
# Restart de la app (rolling, sin downtime)
kubectl rollout restart deployment/rag-api
ChromaDB sigue intacto (no fue decommissionado todavía). Sistema vuelve a estado pre-switch.
Nivel 2 — Rollback de migración inicial (1-2 días)
Si el backfill completo falló o Pinecone tiene calidad significativamente peor:
# 1. Mantener ChromaDB como primario (no cambiar)
# 2. Borrar índice Pinecone (datos migrados son inválidos)
pinecone delete-index --name production-rag
# 3. Análisis de root cause
# 4. Re-planificar migración con fix del problema
Nivel 3 — Re-do de migración con script v2
Si encontramos problema durante validación:
# Identificar qué docs fallaron
failed = json.loads(Path("./failed_batches.json").read_text())
# Migrar solo los failed con script ajustado
for batch_start, namespace in failed:
# ... retry ...
Métricas a monitorear durante el rollout:
- Crítico: error rate de la app (>1% → considerar rollback).
- Latency p95 (>500ms → investigar pero no rollback automático).
- Recall@5 sobre eval set (caída >3% → rollback a chromadb).
- Costo Pinecone (si excede $1000/mes inicial, alertar para investigar).
Resumen y siguiente paso
Lo que aprendiste:
- Migración incremental >> big-bang. Dual-write y dual-read permiten rollback en 5 minutos.
- Export por batches (no cargar todo en RAM). Checkpoint en archivo permite retomar.
- Pinecone metadata: solo str, int, float, bool, list[str]. Dicts anidados deben aplanarse.
- Batch size de 100-200 para upserts. Throttling para no romper rate limits.
- Validación obligatoria post-migración: conteos + sample de IDs + calidad con eval set.
- Feature flag con
VECTOR_DB_BACKENDpermite switch instantáneo en producción. - Rollback plan en tres niveles: inmediato, datos migrados, re-do.
Checkpoint: antes de avanzar, deberías poder:
- Implementar script de migración con retry, throttling, checkpoint.
- Validar integridad post-migración con conteos + sample + eval set.
- Diseñar rollback plan apropiado a tu sistema.
Siguiente cápsula: 05 — Namespaces como aislamiento multi-tenant nativo.
En ChromaDB usabas workspace_id como metadata para aislar tenants. Pinecone tiene un mecanismo más fuerte: namespaces. La cápsula 05 cubre cómo usarlos para aislamiento garantizado a nivel de motor.
Recursos
- Pinecone — Upsert Data — API oficial
- Pinecone — Migration Guide — Patrones de migración
- Exponential Backoff (AWS Best Practices) — Para retry
- Feature Flag Best Practices (LaunchDarkly) — Para rollout
- ChromaDB — Get Operation — Para export
- Pinecone — Rate Limits — Para throttling
Tiempo estimado: 35-40 minutos Siguiente: 05-namespaces-and-multi-tenancy.md