Módulo 4: ChromaDB Setup y Configuración

Cápsula 07: Persistence y Durability — qué pasa cuando algo se rompe

Descripción de la cápsula

Hasta ahora trabajaste como si los datos vivieran para siempre. Insertaste vectores, los buscaste, ajustaste configuración. Pero hay una pregunta que no nos hicimos: ¿qué pasa cuando la máquina se apaga, el proceso crashea, o alguien borra el directorio por error?

En desarrollo, casi siempre la respuesta es "vuelvo a correr el ingestion, son 5 minutos". En producción, es la diferencia entre "el sistema vuelve solo en 30 segundos" y "perdimos 2 días de trabajo de ingestion y los usuarios ven errores hasta que regeneramos todo". Esta cápsula te enseña a configurar persistence correctamente y a diseñar una estrategia de backups que te permita dormir tranquilo cuando tu RAG está en producción.

No es una cápsula glamorosa. No vas a aprender un algoritmo nuevo. Pero el día que algo se rompe en producción, esto es lo que separa "incidente menor" de "post-mortem que dura tres semanas".

Al finalizar esta cápsula serás capaz de:

  • ✅ Diferenciar EphemeralClient, PersistentClient y HttpClient según contexto
  • ✅ Entender la estructura del directorio de datos de ChromaDB y qué archivo guarda qué
  • ✅ Implementar tres estrategias de backup: copia offline, export JSON, snapshot incremental
  • ✅ Diseñar un plan de disaster recovery con RTO y RPO concretos
  • ✅ Recuperar una collection corrupta sin perder datos cuando hay backup disponible
  • ✅ Anticipar el desastre más caro: pensar que tenés backup cuando en realidad no lo verificaste nunca

Tiempo estimado: 25-30 minutos


Tres tipos de cliente, tres garantías de persistencia

ChromaDB ofrece tres modos de cliente. La diferencia operacional importa más de lo que parece.

EphemeralClient — todo en RAM

import chromadb

client = chromadb.Client()  # equivalente a chromadb.EphemeralClient()
collection = client.create_collection("temp")
collection.add(documents=["hello"], ids=["1"])
# Cuando termine el proceso de Python, todo se pierde

Cuándo usar:

  • Tests unitarios (collections que no deben persistir entre tests)
  • Notebooks de exploración (vas a borrar todo de cualquier modo)
  • Demos efímeros

Cuándo NO usar:

  • Cualquier ingestion que tarde más de 5 minutos
  • Cualquier sistema que sirva queries reales
  • Production, casi sin excepción

Riesgo: corres chromadb.Client() por costumbre, hacés ingestion de 1M docs durante 4 horas, cerrás la terminal — perdiste todo. Pasó más veces de las que se admite públicamente.

PersistentClient — datos en disco local

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection("docs")
collection.add(documents=["hello"], ids=["1"])
# Datos en ./chroma_db/, sobreviven al cierre del proceso

Cuándo usar:

  • La mayoría de proyectos: un solo proceso que escribe y lee
  • Setups donde no necesitás compartir datos entre múltiples procesos/máquinas
  • Production de pequeña a mediana escala

Cuándo NO usar:

  • Múltiples procesos accediendo simultáneamente — SQLite serializa pero hay contención
  • Necesitás distribución horizontal (varios servers escribiendo a la misma DB)
  • Tu pipeline de ingestion corre en una máquina y la API de queries en otra

Garantías:

  • Datos persisten entre reinicios del proceso
  • Los writes se commiteán a SQLite + parquet en disco
  • ⚠️ Pero el disco mismo no está respaldado — si la máquina se rompe, perdés todo

HttpClient — server centralizado

# 1) En una máquina, levantar el server:
#    chroma run --path /data/chroma_db --port 8000

# 2) En clientes, conectarse vía HTTP:
client = chromadb.HttpClient(host="chromadb.miempresa.com", port=8000)
collection = client.get_or_create_collection("docs")
collection.add(documents=["hello"], ids=["1"])

Cuándo usar:

  • Múltiples servicios que necesitan compartir la misma collection
  • Separación de capas: ingestion en un host, API en otro
  • Producción de mediana a grande escala con backups centralizados

Garantías:

  • Centralizás backups, monitoring, recursos
  • Costo: latencia de red entre clientes y server (~1-5ms LAN, ~50-100ms WAN)

Trade-off común: PersistentClient para empezar; HttpClient cuando el sistema crece o necesitás múltiples consumidores.


Estructura del directorio de datos

Cuando usás PersistentClient(path="./chroma_db"), esto es lo que se crea:

./chroma_db/
├── chroma.sqlite3              # ← metadata global (collections, IDs, configuraciones)
├── <collection-uuid-1>/
│   ├── data_level0.bin         # ← vectores en formato binario
│   ├── header.bin              # ← header del índice HNSW
│   ├── length.bin
│   ├── link_lists.bin          # ← grafo HNSW (conexiones entre nodos)
│   └── index_metadata.pickle   # ← config HNSW (M, construction_ef, etc.)
├── <collection-uuid-2>/
│   └── ...

Qué guarda cada cosa:

ArchivoQué contieneCuán crítico
chroma.sqlite3Metadata de TODAS las collections, IDs, embeddings comprimidos, metadata de docs🔴 Crítico — perderlo = perder todo
<uuid>/data_level0.binVectores raw del índice HNSW🔴 Crítico — sin esto el índice no funciona
<uuid>/link_lists.binGrafo HNSW (las conexiones entre nodos)🟡 Importante — se puede regenerar pero requiere rebuild de horas
<uuid>/index_metadata.pickleConfig del índice (M, ef)🟢 Reproducible si lo guardaste en código

Tamaño aproximado:

Para 1M vectores × 1536 dim × float32:
  data_level0.bin:           ~6 GB (vectores)
  link_lists.bin:            ~2 GB (grafo HNSW con M=32)
  chroma.sqlite3:            ~500 MB (metadata + IDs)
  total:                     ~8.5 GB

Plan implícito: cualquier estrategia de backup tiene que cubrir el directorio completo. No alcanza con copiar chroma.sqlite3 solo — perderías el índice HNSW.


Tres estrategias de backup

Estrategia 1: copia del directorio (la más simple)

Concepto: parar el proceso de ChromaDB, copiar el directorio entero, reiniciar.

# backup_offline.py
import shutil
import os
from datetime import datetime
from pathlib import Path


def backup_chroma_offline(source_dir: str, backup_root: str) -> str:
    """
    Backup completo del directorio de ChromaDB.
    REQUIERE que el cliente esté cerrado (no haya procesos escribiendo).
    """
    source = Path(source_dir)
    if not source.exists():
        raise FileNotFoundError(f"Source not found: {source_dir}")

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_path = Path(backup_root) / f"chroma_backup_{timestamp}"

    print(f"Backing up {source_dir}{backup_path}")
    shutil.copytree(source, backup_path)

    # Verificar que el backup tiene el archivo crítico
    if not (backup_path / "chroma.sqlite3").exists():
        raise RuntimeError("Backup invalid: chroma.sqlite3 not found")

    size_mb = sum(f.stat().st_size for f in backup_path.rglob("*") if f.is_file()) / 1024 / 1024
    print(f"Backup OK: {size_mb:.1f} MB")
    return str(backup_path)


# Uso (asegurate de que ningún proceso tiene abierta la DB)
backup_path = backup_chroma_offline(
    source_dir="./chroma_db",
    backup_root="./backups",
)

Pros:

  • Simple, predecible, atómico (si la copia se completa, el backup es válido)
  • Recovery es trivial: copiar el directorio de vuelta

Contras:

  • Requiere downtime — no hay escrituras activas durante la copia
  • Si tenés 50 GB de datos, la copia puede tardar minutos

Cuándo usarlo: sistemas con ventana de mantenimiento, datasets pequeños-medianos (<100 GB).

Estrategia 2: export a JSON (portable, lento)

Concepto: exportás el contenido de cada collection a JSON. Lo importás en otro entorno cuando hace falta.

# backup_export.py
import json
from pathlib import Path
import chromadb


def export_collection_to_json(
    collection: chromadb.Collection,
    output_file: str,
    batch_size: int = 1000,
):
    """
    Exporta una collection completa a JSON con sus embeddings.
    Útil para: portabilidad entre entornos, migración entre versiones, archive.
    """
    output_path = Path(output_file)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    total = collection.count()
    print(f"Exporting {total} docs from '{collection.name}'")

    all_data = {
        "name": collection.name,
        "metadata": collection.metadata,
        "documents": [],
        "metadatas": [],
        "embeddings": [],
        "ids": [],
    }

    # Paginar para datasets grandes
    offset = 0
    while offset < total:
        batch = collection.get(
            limit=batch_size,
            offset=offset,
            include=["documents", "metadatas", "embeddings"],
        )
        all_data["documents"].extend(batch["documents"])
        all_data["metadatas"].extend(batch["metadatas"])
        all_data["embeddings"].extend(batch["embeddings"])
        all_data["ids"].extend(batch["ids"])
        offset += batch_size

    with open(output_path, "w") as f:
        json.dump(all_data, f)

    size_mb = output_path.stat().st_size / 1024 / 1024
    print(f"Exported to {output_path} ({size_mb:.1f} MB)")


def import_collection_from_json(
    client: chromadb.Client,
    json_file: str,
    new_collection_name: str | None = None,
    batch_size: int = 200,
) -> chromadb.Collection:
    """Restaura una collection desde un export JSON."""
    with open(json_file) as f:
        data = json.load(f)

    name = new_collection_name or data["name"]
    collection = client.get_or_create_collection(
        name=name,
        metadata=data.get("metadata"),
    )

    # Insertar en batches
    total = len(data["ids"])
    print(f"Importing {total} docs into '{name}'")

    for i in range(0, total, batch_size):
        end = min(i + batch_size, total)
        collection.add(
            documents=data["documents"][i:end],
            metadatas=data["metadatas"][i:end],
            embeddings=data["embeddings"][i:end],  # Reusa embeddings, no recalcula
            ids=data["ids"][i:end],
        )

    print(f"Restored. Verify: collection.count() = {collection.count()}")
    return collection

Pros:

  • Portable: el JSON funciona en cualquier versión de ChromaDB
  • Reusa embeddings — no hay costo de re-embebir con OpenAI
  • Útil para versionar manualmente con git LFS o para migrar entre entornos

Contras:

  • 2-3x más espacio que el formato binario (JSON es ineficiente)
  • Restore es lento: tiene que rebuild el índice HNSW desde cero
  • Para 1M vectores el JSON puede pesar 20+ GB

Cuándo usarlo: migración entre versiones, archive a largo plazo, entornos donde no podés copiar el formato binario.

Estrategia 3: snapshot continuo con metadata de tiempo (incremental)

Concepto: agregás created_at a cada chunk en metadata. Backups incrementales solo cubren chunks nuevos.

# Esquema de metadata con timestamp
import time

def add_with_timestamp(collection, documents, ids, metadatas=None):
    now = int(time.time())
    metas_with_time = []
    for i, m in enumerate(metadatas or [{}] * len(documents)):
        meta_copy = dict(m)
        meta_copy["created_at"] = now
        metas_with_time.append(meta_copy)
    collection.add(documents=documents, ids=ids, metadatas=metas_with_time)


def export_incremental(collection, since_timestamp: int, output_file: str):
    """Exporta solo documentos creados después de since_timestamp."""
    new_docs = collection.get(
        where={"created_at": {"$gte": since_timestamp}},
        include=["documents", "metadatas", "embeddings"],
    )
    print(f"Found {len(new_docs['ids'])} docs since {since_timestamp}")

    with open(output_file, "w") as f:
        json.dump(new_docs, f)

    return len(new_docs["ids"])

Pros:

  • Backups incrementales son mucho más rápidos que full
  • Útil cuando el dataset crece pero pocos docs cambian al día

Contras:

  • Requiere disciplina: TODOS los inserts tienen que poner created_at
  • ChromaDB no tiene timestamps automáticos — vos los agregás
  • Si te equivocás en una migración, el incremental no atrapa esos cambios

Cuándo usarlo: sistemas con mucho ingestion incremental (logs de chat, eventos, docs nuevos diarios).


Disaster recovery: RTO y RPO

Antes de diseñar tu estrategia, definí dos números:

RTO (Recovery Time Objective): ¿cuánto tiempo podés tolerar que el sistema esté caído? "Si la DB se corrompe, ¿en cuántos minutos/horas tenemos que estar de vuelta?"

RPO (Recovery Point Objective): ¿cuántos datos podés tolerar perder? "Si la DB se corrompe, ¿es aceptable perder los últimos X minutos/horas de ingestion?"

Ejemplos por tipo de sistema:

SistemaRTORPO
Demo internoDíasDías (re-ingest manual)
Tool de productividad<4 horas<24 horas
RAG corporativo de soporte<1 hora<1 hora
Sistema crítico (médico, legal)<15 min<15 min

Tu estrategia se deriva de estos números:

  • RTO < 1 hora: copia del directorio + script de restore probado. 30-50 GB se restauran en ~10-15 minutos.
  • RPO < 1 hora: backups cada hora, no diarios. Si tu cron es 0 2 * * * (2 AM diario), tenés RPO de 24 horas.
  • RTO < 15 min: standby caliente — un segundo nodo con replicación continua. Va más allá del scope de ChromaDB básico, requiere setup distribuido (cubierto en M07 y guía #18).

Plan operativo mínimo para producción:

# disaster_recovery_plan.yml (concepto)
backup_strategy:
  type: directory_copy_offline
  frequency: every 6 hours
  retention: 7 days
  destination: s3://my-backups/chroma/
  verify: yes (intentar abrir el backup en cada run)

monitoring:
  - alert si último backup > 12 horas atrás
  - alert si tamaño del backup difiere ±20% del anterior
  - alert si verify falla

runbook_recovery:
  - 1. Detectar corrupción (queries fallan, count() inconsistent)
  - 2. Identificar último backup válido (verify pasa)
  - 3. Stop service
  - 4. mv chroma_db chroma_db_corrupt_$(date)
  - 5. Restore: aws s3 sync s3://backups/<latest> ./chroma_db
  - 6. Verify count() vs expected
  - 7. Start service
  - 8. Notificar pérdida de datos posteriores al backup (en RPO window)

Verificar backups (la parte que casi nadie hace)

El antipatrón más caro: asumir que tenés backups porque corrés un script. El día del incidente descubrís que:

  • El backup está corrupto
  • Solo se backupeó parte del directorio
  • El backup es de hace 3 semanas porque el cron paró silenciosamente

Cómo evitarlo:

def verify_backup(backup_path: str) -> bool:
    """
    Verifica que un backup sea funcional intentando abrirlo y hacer una query.
    Devuelve True solo si todo funciona.
    """
    backup = Path(backup_path)

    # 1. Files críticos existen
    if not (backup / "chroma.sqlite3").exists():
        print(f"❌ Falta chroma.sqlite3 en {backup_path}")
        return False

    # 2. Tamaño razonable
    size_mb = sum(f.stat().st_size for f in backup.rglob("*") if f.is_file()) / 1024 / 1024
    if size_mb < 1:
        print(f"❌ Backup sospechosamente chico ({size_mb:.1f} MB)")
        return False

    # 3. Abrir como cliente
    try:
        client = chromadb.PersistentClient(path=str(backup))
        collections = client.list_collections()
    except Exception as e:
        print(f"❌ No se puede abrir como ChromaDB: {e}")
        return False

    if not collections:
        print(f"⚠️ Backup sin collections")
        return False

    # 4. Hacer una query trivial sobre cada collection
    for col in collections:
        try:
            count = col.count()
            if count == 0:
                print(f"⚠️ Collection '{col.name}' vacía")
                continue
            # Query test
            sample = col.peek(limit=1)
            if not sample["ids"]:
                print(f"❌ Collection '{col.name}': peek devolvió vacío")
                return False
        except Exception as e:
            print(f"❌ Collection '{col.name}': error en peek - {e}")
            return False

    print(f"✅ Backup válido: {len(collections)} collections, {size_mb:.1f} MB")
    return True


# Integrar en el cron de backup
def backup_with_verification(source: str, backup_root: str) -> str:
    backup_path = backup_chroma_offline(source, backup_root)
    if not verify_backup(backup_path):
        raise RuntimeError(f"Backup verification FAILED: {backup_path}")
    return backup_path

Frecuencia recomendada de verify:

  • Cada backup: al menos chequeo básico (existe, tamaño OK).
  • Semanal: restore completo a entorno de staging y queries reales.
  • Trimestral: disaster recovery drill — simular incidente y medir RTO real.

Trampas y errores comunes

Trampa 1: backup mientras el proceso escribe

El error:

# Mientras este proceso está activo:
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_collection("docs")
# ... insertando data...

# Otro proceso intenta hacer backup:
shutil.copytree("./chroma_db", "./backup_chroma_db_NOW")

Síntoma: el backup queda parcialmente escrito o corrupto. SQLite puede tener writes pendientes.

Cómo prevenir: parar el cliente antes de copiar. Si el sistema necesita 24/7 disponibilidad, usar replicación (fuera de scope de ChromaDB básico).

Workaround: snapshot a nivel de filesystem (LVM snapshot, ZFS snapshot) que captura un punto consistente atómicamente.

Trampa 2: backup solo del archivo SQLite

El error: "el archivo importante es chroma.sqlite3, copio solo eso".

Síntoma: al restaurar, la collection existe pero el índice HNSW está vacío o inconsistente. Las queries devuelven cero resultados.

Cómo prevenir: backup el directorio entero. Los archivos *.bin del HNSW son tan críticos como chroma.sqlite3.

Trampa 3: cron de backup que falla silenciosamente

El error: programaste 0 2 * * * para hacer backup diario. Funcionó por 6 meses. Después dejó de correr porque el cron user no tenía permisos en el destino. Nadie se enteró.

Síntoma: el día del incidente, backup más reciente es de hace 4 meses.

Cómo prevenir:

  • Health check: la API expone /last_backup_timestamp. Monitoring alerta si > 26 horas atrás (margen sobre el ciclo diario).
  • Alertas en el cron: redirigir output a archivo + revisarlo, o usar herramienta como healthchecks.io que alerta si no recibe ping.

Trampa 4: no probar el restore

El error: tenés backups diarios desde hace 2 años. Nunca los probaste. El día del incidente, descubrís que el formato cambió entre versiones de ChromaDB y el backup viejo no se puede abrir con el binario actual.

Cómo prevenir:

  • DR drill trimestral: tomá un backup random, restauralo en staging, hacé queries. Mide RTO real.
  • Documentar versión de ChromaDB del backup en el nombre del archivo (chroma_backup_v0.5.3_20260508.tar.gz).

Trampa 5: backups en la misma máquina

El error: disco de la máquina muere. Tenías backups en /var/backups/chroma/ — la misma máquina que perdiste.

Cómo prevenir: 3-2-1 rule: 3 copias, en 2 medios distintos, 1 fuera del sitio.

  • 1 copia en producción (el sistema vivo)
  • 1 backup en disco distinto de la misma máquina (rápido para recover de errores menores)
  • 1 backup en almacenamiento remoto (S3, GCS, Backblaze) para recover de desastre del sitio

Trampa 6: retention infinito = costo infinito

El error: mantenés todos los backups desde el día 1. Después de 2 años con backups diarios + backup pesa 50 GB → 36 TB de storage.

Cómo prevenir: retention policy clara:

  • Daily backups: keep últimos 7 días.
  • Weekly backups: keep últimas 4 semanas.
  • Monthly backups: keep últimos 12 meses.
  • Yearly backups (compliance): keep N años según regulación.

Implementá con script de cleanup automático:

def cleanup_old_backups(backup_root: str, retention_days: int = 7):
    cutoff = time.time() - (retention_days * 86400)
    for backup_dir in Path(backup_root).iterdir():
        if backup_dir.is_dir() and backup_dir.stat().st_ctime < cutoff:
            shutil.rmtree(backup_dir)
            print(f"Deleted old backup: {backup_dir.name}")

Ejercicio aplicado

Escenario: sos AI Engineer en una empresa de servicios legales. Operás un RAG con:

  • 500K chunks de jurisprudencia, ChromaDB persistente, ~4 GB en disco
  • 50-100 queries/hora, principalmente entre 9 AM y 7 PM
  • Compliance regulatorio: deben poder reconstruir respuestas de hasta 90 días atrás
  • Stakeholder dice: "necesitamos garantías de que nunca perdemos datos"

Tu trabajo: diseñá la estrategia de backup completa. Definí RTO, RPO, frecuencia, retention, ubicación de backups, monitoring y runbook de recovery.

Solución

Análisis de los requisitos:

  1. Compliance "reconstruir respuestas hasta 90 días": implica retener el dataset histórico (con sus chunks tal como estaban) por mínimo 90 días.
  2. "Nunca perdemos datos": stakeholder pide RPO=0, pero esto es prácticamente imposible. Negociar a RPO realista (15-60 min).
  3. Servicios legales: errores tienen consecuencias serias. RTO debe ser bajo (<2 horas).

Definición de RTO/RPO negociada:

MétricaTargetJustificación
RTO< 1 horaServicio interno, ventana de tolerancia razonable
RPO< 30 minutosCompromiso entre "nunca perder" y costo operativo

Estrategia propuesta:

1. Backup frequency

  • Hot backup (cada 30 minutos): snapshot incremental durante horas activas (8 AM - 8 PM).

    • Implementación: rsync incremental del directorio chroma_db a un disco secundario.
    • 8 horas activas × 2/h = 16 backups por día.
  • Full backup (diario, 3 AM): copia completa del directorio durante ventana de baja actividad.

    • Implementación: stop server, tar.gz del directorio, start server. Downtime esperado <5 min.

2. Storage

Local (disco rápido, dentro de la máquina):

  • /var/backups/chroma/hot/ — últimos 24 horas de hot backups (48 archivos × ~4 GB = 192 GB)
  • /var/backups/chroma/daily/ — últimos 7 días (7 × 4 GB = 28 GB)

Remoto (S3 o GCS):

  • Daily backups: keep 90 días (compliance requirement) — 90 × 4 GB = 360 GB
  • Weekly backups: keep 12 meses
  • Monthly backups: keep 5 años

Total storage estimado en cloud: 750 GB ($15/mes en S3 Standard, $5/mes en S3 Glacier para >90 días)

3. Retention policy

RETENTION = {
    "hot_backup": "24 hours",
    "daily_backup_local": "7 days",
    "daily_backup_remote": "90 days",   # compliance
    "weekly_backup": "12 months",
    "monthly_backup": "5 years",        # compliance + audit
}

4. Implementation

# /etc/cron.d/chroma_backup

# Hot backup every 30 min during business hours
*/30 8-19 * * 1-5 /usr/local/bin/chroma_hot_backup.sh

# Daily full backup at 3 AM
0 3 * * * /usr/local/bin/chroma_daily_backup.sh

# Weekly backup Sundays at 4 AM
0 4 * * 0 /usr/local/bin/chroma_weekly_backup.sh

# Cleanup old backups daily
0 5 * * * /usr/local/bin/chroma_cleanup_backups.sh
# chroma_daily_backup.sh
#!/bin/bash
set -euo pipefail

DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/var/backups/chroma/daily/chroma_${DATE}"
S3_BUCKET="s3://legal-rag-backups/chroma/daily/"

# 1. Stop service
systemctl stop chroma-rag

# 2. Backup
rsync -a /data/chroma_db/ "${BACKUP_DIR}/"

# 3. Start service
systemctl start chroma-rag

# 4. Verify
python /usr/local/bin/verify_backup.py "${BACKUP_DIR}"

# 5. Compress and upload to S3
tar czf "${BACKUP_DIR}.tar.gz" -C "$(dirname ${BACKUP_DIR})" "$(basename ${BACKUP_DIR})"
aws s3 cp "${BACKUP_DIR}.tar.gz" "${S3_BUCKET}"

# 6. Notify health check
curl -fsS -o /dev/null https://hc-ping.com/{check-uuid}

# 7. Cleanup local archive (keep only directory, S3 has the tar.gz)
rm "${BACKUP_DIR}.tar.gz"

5. Monitoring y alertas

# Métricas exportadas por el sistema
metrics = {
    "chroma_backup_last_success_timestamp": <unix_ts>,
    "chroma_backup_last_size_bytes": <size>,
    "chroma_backup_verify_success": 0 | 1,
}

# Alertas en Datadog/Grafana
alerts = [
    {
        "name": "ChromaBackupStale",
        "condition": "now() - chroma_backup_last_success_timestamp > 90 minutes",
        "severity": "page_oncall",
        "runbook": "https://wiki/runbooks/chroma-backup-stale",
    },
    {
        "name": "ChromaBackupSizeAnomaly",
        "condition": "abs(size_today - size_yesterday) / size_yesterday > 0.20",
        "severity": "ticket",
        "runbook": "https://wiki/runbooks/chroma-backup-size",
    },
    {
        "name": "ChromaBackupVerifyFailed",
        "condition": "chroma_backup_verify_success == 0",
        "severity": "page_oncall",
        "runbook": "https://wiki/runbooks/chroma-backup-verify",
    },
]

6. Runbook de recovery

# Runbook: Chroma DB corruption / data loss

## Detection
- Queries fallan con error de schema
- `collection.count()` devuelve número inesperado
- Logs muestran corrupción de SQLite o HNSW

## Recovery (target RTO: 1 hour)

1. **Stop service** (5 min)
   ```bash
   systemctl stop chroma-rag
  1. Identify last valid backup (10 min)

    # Try most recent first
    for backup in $(ls -t /var/backups/chroma/hot/ | head -5); do
        python /usr/local/bin/verify_backup.py "/var/backups/chroma/hot/${backup}"
        if [ $? -eq 0 ]; then
            VALID="/var/backups/chroma/hot/${backup}"
            break
        fi
    done
    echo "Restoring from: ${VALID}"
  2. Backup corrupt state (5 min)

    mv /data/chroma_db /data/chroma_db_corrupt_$(date +%Y%m%d_%H%M)
  3. Restore (15 min para 4 GB local)

    rsync -a "${VALID}/" /data/chroma_db/
  4. Verify and start (5 min)

    python /usr/local/bin/verify_post_restore.py
    systemctl start chroma-rag
    curl http://localhost:8000/health
  5. Post-incident (within 24 hours)

    • Notificar usuarios sobre RPO window (datos perdidos < 30 min)
    • Análisis del directorio corrupto para diagnostic
    • Post-mortem en wiki

Total RTO target: 40 min


### 7. DR drill (trimestral)

Cada 3 meses:
1. Tomar un backup random de los últimos 90 días.
2. Restaurarlo en entorno de staging.
3. Ejecutar query suite predefinida.
4. Documentar tiempo total y problemas encontrados.
5. Si tiempo > target, ajustar pipeline.

**Costo total estimado:**

- Storage S3: $20/mes
- Compute para hot backups (rsync incremental): negligible
- Tiempo de operador: ~2 horas/mes (drill trimestral + ajustes)

**Comunicación al stakeholder:**

> *"Implementamos backup cada 30 min en horas activas + full backup diario, con retention de 90 días en S3 (compliance) y 5 años para weekly/monthly. RTO target 1 hora, RPO 30 min. Lo que NO podemos garantizar es 'cero pérdida' — eso requeriría replicación síncrona a un standby caliente, lo cual triplica el costo de infra y no se justifica para nuestro caso. Nuestro RPO de 30 min significa que en el peor escenario (corrupción justo después del último hot backup), podemos perder hasta 30 minutos de queries logueadas. Para los chunks del dataset legal, la pérdida sería cero porque solo cambian con releases programados."*

</details>

---

## Resumen y siguiente paso

**Lo que aprendiste:**

- Tres modos de cliente: `EphemeralClient` (RAM, no persiste), `PersistentClient` (disco local), `HttpClient` (server centralizado).
- El directorio de ChromaDB tiene `chroma.sqlite3` (metadata global) + subdirectorios por collection con archivos binarios del índice HNSW.
- Tres estrategias de backup: copia offline del directorio (más simple), export JSON (portable, lento), incremental con timestamps (eficiente para datasets que crecen).
- RTO y RPO son los dos números que definen tu estrategia. Acordalos con stakeholders antes de elegir tecnología.
- Verificar backups en cada run + DR drill periódico es lo que separa "tener backups" de "tener backups que sirven".
- Regla 3-2-1: 3 copias, 2 medios, 1 fuera del sitio. Backup en la misma máquina no es backup real.
- Retention policy explícita previene que el storage crezca infinitamente.

**Checkpoint:** antes de avanzar, deberías poder:

- [ ] Diferenciar `EphemeralClient` vs `PersistentClient` vs `HttpClient` y cuándo usar cada uno.
- [ ] Explicar por qué backup solo de `chroma.sqlite3` no es suficiente.
- [ ] Definir RTO y RPO realistas para un sistema dado.
- [ ] Diseñar verificación automática de backups en el pipeline operativo.

**Siguiente cápsula: 08 — Mini-proyecto Document Search.**

Cerrás el primer arco de M4 (cápsulas 01-08) construyendo un Document Search System completo: 10K documentos, batch ingestion validado, queries con metadata filtering, persistence, y benchmarks que validen p95 <20ms con throughput >1K docs/sec. Es la consolidación práctica de todo lo que aprendiste antes de pasar al arco RAG (cápsulas 09-11) que cubre embeddings con OpenAI, chunking y pipeline RAG end-to-end.

---

## Recursos

1. [ChromaDB — Persistence](https://docs.trychroma.com/usage-guide#initiating-a-persistent-chroma-client) — Documentación oficial de PersistentClient
2. [ChromaDB — Running as a Server](https://docs.trychroma.com/deployment) — Setup de HttpClient con server centralizado
3. [SQLite — Backup API](https://www.sqlite.org/backup.html) — Cómo SQLite maneja backup atómico
4. [Google SRE — Backup and Recovery](https://sre.google/sre-book/data-integrity/) — Principios generales de backup
5. [The 3-2-1 Backup Rule](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) — Regla clásica de backup
6. [Postgres backup-and-restore concepts (aplicables a ChromaDB)](https://www.postgresql.org/docs/current/backup.html) — Buenas prácticas transferibles

---

**Tiempo estimado:** 25-30 minutos
**Siguiente:** [08-mini-proyecto-document-search.md](08-mini-proyecto-document-search.md)