Module 7: Production with Pinecone — the migration from "working demo" to "24/7 service"
Capsule 04: The ChromaDB → Pinecone migration — without losing data or breaking queries
Capsule description
The actual migration is where most projects get complicated. It isn't "export from ChromaDB and dump into Pinecone" — that fails 50% of the time. The difference between a smooth migration and an incident is in the details: integrity validation, per-batch error handling, dual-write during the transition, a rollback plan, and A/B testing before the full switchover.
This capsule teaches you the right migration: an incremental strategy with minimal downtime, robust scripts with exponential retry, count and integrity validation after every step, and a rollback plan if something goes wrong in production.
By the end of this capsule you'll be able to:
- ✅ Implement a batched migration script with retry and logging
- ✅ Validate integrity: counts, preserved metadata, consistent embeddings
- ✅ Design an incremental strategy (not big-bang) with dual-write during the transition
- ✅ Implement a rollback plan with a feature flag
- ✅ Validate retrieval quality post-migration with an eval set
- ✅ Anticipate the four most common errors: payload size, metadata types, the wrong namespace, a dimension mismatch
Estimated time: 35-40 minutes
The strategy: incremental, not big-bang
BIG-BANG (dangerous)
Day 1: Stop the app
Day 1-3: Migrate everything to Pinecone
Day 3: Switch the app to Pinecone
Day 3+: Hope it works
If something fails: the rollback takes hours, extended downtime
INCREMENTAL (recommended)
Week 1: Pinecone setup + the migration script + tests
Week 2: Dual-write (write to ChromaDB AND Pinecone for every new doc)
Week 3: Backfill (migrate the ChromaDB history into Pinecone)
Week 4: Dual-read with an A/B test (query both, compare)
Week 5: Switch the primary to Pinecone, ChromaDB as the fallback
Week 6: Decommission ChromaDB
If something fails: rollback in minutes via a feature flag
Incremental migration is the rule. Big-bang is only justified for a small corpus (<100K docs) or systems with planned downtime.
The base script: exporting from 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]:
"""
Exports documents in batches so you don't load everything into RAM.
Yields batches of dicts with id, embedding, metadata.
"""
chroma_client = chromadb.PersistentClient(path="./chroma_db")
collection = chroma_client.get_collection(collection_name)
total = collection.count()
print(f"Exporting {total} docs from '{collection_name}'")
offset = 0
while offset < total:
batch = collection.get(
limit=batch_size,
offset=offset,
include=["embeddings", "metadatas", "documents"],
)
# Yield the batch as a list of 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" Exported {min(offset, total)}/{total}")
Why batches: large collections (5M+ docs) don't fit in RAM. A batch of 1000 keeps RAM at ~500MB.
Upserting to Pinecone with retry
import time
from pinecone import Pinecone
def upsert_to_pinecone(
index,
items: list,
namespace: str = "",
max_retries: int = 3,
) -> int:
"""
Upserts a batch to Pinecone with exponential retry.
Returns the count of successfully inserted items.
"""
# Build the vectors in Pinecone's format
vectors = []
for item in items:
# Pinecone metadata must be serializable and flat
metadata = clean_metadata_for_pinecone(item["metadata"])
# Add the document's content as metadata for later retrieval
metadata["content"] = item["document"][:40000] # Pinecone limit
vectors.append({
"id": item["id"],
"values": item["embedding"],
"metadata": metadata,
})
# Upsert with exponential retry
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:
# The last attempt, re-raise
raise
wait_time = 2 ** attempt # 1, 2, 4 seconds
print(f" Error on attempt {attempt + 1}: {e}. Retrying in {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):
# Convert everything to strings
cleaned[key] = [str(v) for v in value]
elif value is None:
# Skip None values
continue
elif isinstance(value, dict):
# Flatten the 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 other types to a string
cleaned[key] = str(value)
return cleaned
The important details:
- Pinecone has a limit of 40,000 characters per metadata field. Truncate
content. - Metadata must be flat (no nested dicts). Flatten it if necessary.
- Only primitive types in metadata: str, int, float, bool, list[str].
Integrity validation
After migrating, validate that NOTHING was lost:
def validate_migration(
chroma_collection,
pinecone_index,
namespace: str = "",
) -> dict:
"""
Post-migration validations:
1. The counts match
2. A sample of random IDs exists in both
3. The metadata is preserved
"""
# 1. The counts
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. A sample of random IDs
import random
chroma_data = chroma_collection.get(limit=20) # the first 20 as a 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. Validate that the metadata is preserved (a 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"]
# Compare the critical fields
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),
}
# Usage
result = validate_migration(chroma_collection, pinecone_index, namespace="acme_corp")
if result["valid"]:
print("✅ Migration validated")
else:
print(f"❌ Migration FAILED: {result['error']}")
Quality validation: a comparative eval set
Matching counts aren't enough. Validate that the retrieval gives similar results:
def validate_quality_post_migration(
chroma_collection,
pinecone_index,
eval_set: list,
threshold_recall: float = 0.85,
):
"""
Compares the retrieval's recall between ChromaDB and Pinecone.
If Pinecone has significantly lower recall, the migration has a problem.
"""
chroma_recalls = []
pinecone_recalls = []
for item in eval_set:
# The 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])
# The 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 the 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: # more than 3% worse
return {
"valid": False,
"error": f"Pinecone recall is {delta:+.2%} worse than ChromaDB",
}
return {"valid": True, "chroma_recall": avg_chroma, "pinecone_recall": avg_pinecone}
The incremental strategy with a feature flag
During the transition, we want to be able to switch instantly between ChromaDB and 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 that respects the VECTOR_DB_BACKEND feature flag."""
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":
# For A/B testing: run both, return chromadb's but log both
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}")
The rollout plan:
- Deploy with
VECTOR_DB_BACKEND=chromadb(no change). - Set
VECTOR_DB_BACKEND=dualin production → it generates comparison logs. - If after 1 week the logs show consistent results, switch to
pinecone. - If something's wrong, roll back to
chromadbwith an env var change (5 minutes, no redeploy).
Traps and common mistakes
Trap 1: a payload that's too large
The mistake: a batch of 5000 vectors with 3072-dim embeddings each.
The symptom: RequestError: Request body too large. Pinecone has a ~2MB limit per request.
How to prevent it: a batch_size of 100-200 with 1536d embeddings. If the dim is larger, a smaller batch_size.
Trap 2: metadata with unsupported types
The mistake:
metadata = {
"tags": ["a", "b"],
"config": {"nested": "dict"}, # ← Pinecone doesn't support a nested dict
"score": np.float64(0.5), # ← a numpy type
}
The symptom: ValidationError: metadata field "config" type not supported.
How to prevent it: clean_metadata_for_pinecone (shown above) flattens the dicts and converts the types.
Trap 3: a silent dimension mismatch
The mistake: ChromaDB has 1536d embeddings. The Pinecone index has dim=384 (left over from a test).
The symptom: Vector dimension 1536 does not match index dimension 384.
How to prevent it: validate the first batch's dimension before processing everything.
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}"
)
Trap 4: the wrong namespace
The mistake: forgetting the namespace in the upsert. Everything ends up in the default namespace "".
The symptom: queries filtering by namespace come back empty.
How to prevent it: the namespace is always mandatory in the wrappers:
def safe_upsert(index, vectors, workspace_id):
if not workspace_id:
raise ValueError("workspace_id is mandatory")
namespace = f"ws_{workspace_id}"
index.upsert(vectors=vectors, namespace=namespace)
Trap 5: not rate-limiting the migration script
The mistake: the script parallelizes the upsert with 50 concurrent workers.
The symptom: Pinecone rate limits, 429 errors, and the migration fails halfway.
How to prevent it: Pinecone Standard allows ~1000 upserts/second. With a batch of 100, that's 10 batches/second. Anything more requires a higher plan.
# Throttling
time.sleep(0.05) # 50ms between batches → 20 batches/second
Trap 6: the migration cutting out halfway
The mistake: the VM runs out of memory halfway through. You have 3M of 5M docs migrated.
The symptom: a mixed state, and you can't tell what's missing.
How to prevent it: track the progress by offset in a file. If the script fails, resume from the last 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))
Applied exercise
Scenario: you're an AI Engineer and you have to migrate to Pinecone. The data:
- ChromaDB with 3M chunks from 50 tenants
- Model: OpenAI text-embedding-3-small (1536 dim)
- The system is in production 24/7, there's no maintenance window
- Team: 3 people
- Deadline: 2 weeks
Your job:
- Design the incremental migration plan.
- Implement pseudo-code for the main script.
- Define the rollback plan.
Solution
1. The migration plan (2 weeks)
Days 1-2: Setup + tests
- Create the Pinecone index (capsule 03)
- Implement export_from_chromadb + upsert_to_pinecone
- Tests with a small corpus (10K docs in staging)
- Validate dimension/metric/quality
Days 3-5: The historical backfill (3M docs)
- A migration script with checkpointing (resumable)
- Throttling so we don't hit the rate limits
- Logging of every batch + exponential retry
- Estimated time: 30-45 minutes of pure work, ~3-4 hours with throttling
Days 6-7: Post-backfill validation
- The counts match (ChromaDB vs Pinecone)
- Sample 100 random IDs, validate the metadata is preserved
- A comparative eval set (recall must be within 2% of the baseline)
Days 8-9: Dual-write
- The app writes new docs into BOTH systems
- Logs catch the inconsistencies
- VECTOR_DB_BACKEND=chromadb (the queries still go there)
Days 10-12: Dual-read with an A/B
- VECTOR_DB_BACKEND=dual
- Queries go to chromadb (primary) and pinecone (shadow)
- The logs compare the results
- If the quality delta is <2%, we're OK to switch
Day 13: Switch the primary
- VECTOR_DB_BACKEND=pinecone
- ChromaDB stays as the fallback in case of errors
- Extra monitoring for 24-48 hours
Day 14: Decommission
- If Pinecone is stable for 48 hours, deprecate ChromaDB
- A final ChromaDB backup to retain
- Clean up the legacy code
2. Pseudo-code for the main script
# 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 from the checkpoint if it exists
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 per 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 the checkpoint
CHECKPOINT_FILE.write_text(str(batch_start + BATCH_SIZE))
# Progress log
progress = (batch_start + BATCH_SIZE) / total * 100
print(f" [{progress:.1f}%] Migrated up to offset {batch_start + BATCH_SIZE}")
# Throttle
time.sleep(THROTTLE_MS / 1000)
# The final report
print(f"\nMigration complete.")
print(f"Failed batches: {failed_batches}")
if failed_batches:
# Save the list for a manual retry
Path("./failed_batches.json").write_text(json.dumps(failed_batches))
if __name__ == "__main__":
migrate()
3. The rollback plan
The rollback levels (from least to most severe):
Level 1 — Immediate rollback (5 minutes)
If after the switch to Pinecone (day 13) we detect a problem:
# Change the env var in production
export VECTOR_DB_BACKEND=chromadb
# Restart the app (rolling, no downtime)
kubectl rollout restart deployment/rag-api
ChromaDB is still intact (it hasn't been decommissioned yet). The system returns to its pre-switch state.
Level 2 — Rolling back the initial migration (1-2 days)
If the full backfill failed or Pinecone's quality is significantly worse:
# 1. Keep ChromaDB as the primary (don't change it)
# 2. Delete the Pinecone index (the migrated data is invalid)
pinecone delete-index --name production-rag
# 3. Root cause analysis
# 4. Re-plan the migration with the problem fixed
Level 3 — Redoing the migration with a v2 script
If we find a problem during validation:
# Identify which docs failed
failed = json.loads(Path("./failed_batches.json").read_text())
# Migrate only the failed ones with the adjusted script
for batch_start, namespace in failed:
# ... retry ...
Metrics to monitor during the rollout:
- Critical: the app's error rate (>1% → consider a rollback).
- p95 latency (>500ms → investigate, but no automatic rollback).
- Recall@5 over the eval set (a drop >3% → roll back to chromadb).
- Pinecone cost (if it exceeds the initial $1000/month, alert and investigate).
Summary and next step
What you learned:
- Incremental migration >> big-bang. Dual-write and dual-read allow a rollback in 5 minutes.
- Export in batches (don't load everything into RAM). A file checkpoint lets you resume.
- Pinecone metadata: only str, int, float, bool, list[str]. Nested dicts have to be flattened.
- A batch size of 100-200 for the upserts. Throttling so you don't hit the rate limits.
- Mandatory post-migration validation: counts + a sample of IDs + quality with the eval set.
- A feature flag with
VECTOR_DB_BACKENDallows an instant switch in production. - A rollback plan on three levels: immediate, the migrated data, a redo.
Checkpoint: before moving on, you should be able to:
- Implement a migration script with retry, throttling, checkpointing.
- Validate post-migration integrity with counts + a sample + the eval set.
- Design a rollback plan appropriate to your system.
Next capsule: 05 — Namespaces as native multi-tenant isolation.
In ChromaDB you used workspace_id as metadata to isolate tenants. Pinecone has a stronger mechanism: namespaces. Capsule 05 covers how to use them for isolation guaranteed at the engine level.
Resources
- Pinecone — Upsert Data — The official API
- Pinecone — Migration Guide — Migration patterns
- Exponential Backoff (AWS Best Practices) — For the retry
- Feature Flag Best Practices (LaunchDarkly) — For the rollout
- ChromaDB — Get Operation — For the export
- Pinecone — Rate Limits — For the throttling
Estimated time: 35-40 minutes Next: 05-namespaces-and-multi-tenancy.md