Module 7: Production with Pinecone — the migration from "working demo" to "24/7 service"

Capsule 03: Setting up serverless Pinecone — your first production-ready index in 30 minutes

Capsule description

You decided to migrate (capsule 02). Now comes the first concrete step: creating your Pinecone index. It sounds simple — and it is — but there are three decisions you make when creating the index that you can NOT change later without recreating everything: dimensions, distance metric, and region/cloud. This capsule teaches you to make those decisions right the first time.

You'll configure a serverless index (no thinking about infrastructure), validate that the dimensions match your embedding model, and leave everything ready with idempotent scripts that any dev on the team can re-run without breaking state.

By the end of this capsule you'll be able to:

  • ✅ Create a serverless Pinecone index with the right dimension and metric
  • ✅ Configure API keys and environment variables properly
  • ✅ Implement an idempotent setup that can run in any environment (dev, staging, prod)
  • ✅ Validate that the index's dimension matches the embedding model before indexing
  • ✅ Decide between serverless and pod-based for your case
  • ✅ Anticipate migration mistake #1: incompatible dimensions between the index and the embeddings

Estimated time: 30-35 minutes


The three immovable decisions when creating an index

When you create a Pinecone index, three parameters are fixed forever. Changing them requires recreating the index and re-indexing everything:

1. Dimension

pc.create_index(
    name="my-rag",
    dimension=1536,  # ← it gets fixed here
    ...
)

How to choose: it must match EXACTLY your embedding model's dimension.

ModelDimension
OpenAI text-embedding-3-small1536
OpenAI text-embedding-3-large3072
OpenAI text-embedding-ada-002 (legacy)1536
Cohere embed-english-v31024
Cohere embed-multilingual-v31024
Sentence Transformers all-MiniLM-L6-v2384
Voyage voyage-21024

A common trap: creating an index with dim=1536 (assuming OpenAI), then later deciding to migrate to text-embedding-3-large (dim=3072). The index won't accept the new embeddings — you have to recreate it.

2. Distance metric

pc.create_index(
    name="my-rag",
    metric="cosine",  # ← it gets fixed here
    ...
)

The options:

  • cosine — the default for text embeddings from OpenAI, Cohere, Sentence Transformers
  • euclidean (L2) — some specific cases (image embeddings, recommendations)
  • dotproduct — when the embeddings are already normalized

The recommendation: for 95% of RAG cases with text embeddings, cosine. If your embedding model recommends another metric, use that one.

3. Region and cloud

pc.create_index(
    name="my-rag",
    spec=ServerlessSpec(
        cloud="aws",         # aws | gcp | azure
        region="us-east-1",  # ← choose it by latency to your users
    ),
)

How to choose the region:

  • If your app is in AWS us-east-1, put the index in us-east-1 too.
  • Network latency: ~5-15ms in the same region, 50-100ms across regions.
  • Pinecone serverless doesn't support automatic multi-region replication (Enterprise does).

Setup, step by step

Step 1: installation and configuration

pip install "pinecone-client>=4.0"
# .env
PINECONE_API_KEY=...  # from the Pinecone console
PINECONE_INDEX_NAME=production-rag
PINECONE_ENVIRONMENT=production

Step 2: the client and a check

# pinecone_setup.py
import os
from pinecone import Pinecone, ServerlessSpec
from dotenv import load_dotenv


load_dotenv()


def get_pinecone_client() -> Pinecone:
    """Initializes the Pinecone client with the API key."""
    api_key = os.getenv("PINECONE_API_KEY")
    if not api_key:
        raise EnvironmentError("PINECONE_API_KEY is not configured")
    return Pinecone(api_key=api_key)


# A quick smoke test
pc = get_pinecone_client()
print(f"Existing indexes: {pc.list_indexes().names()}")

Step 3: idempotent index creation

def ensure_index(
    pc: Pinecone,
    name: str,
    dimension: int = 1536,
    metric: str = "cosine",
    cloud: str = "aws",
    region: str = "us-east-1",
):
    """
    Creates the index if it doesn't exist. If it exists, validates the config
    and returns a reference.
    Idempotent: it can be run multiple times without breaking anything.
    """
    existing_indexes = [idx.name for idx in pc.list_indexes()]

    if name in existing_indexes:
        # Validate that the existing config matches
        index_info = pc.describe_index(name)
        if index_info.dimension != dimension:
            raise ValueError(
                f"Index '{name}' exists with dimension {index_info.dimension}, "
                f"expected {dimension}. Recreate it or use another name."
            )
        if index_info.metric != metric:
            raise ValueError(
                f"Index '{name}' exists with metric {index_info.metric}, "
                f"expected {metric}."
            )
        print(f"Index '{name}' already exists, config OK")
    else:
        print(f"Creating index '{name}'...")
        pc.create_index(
            name=name,
            dimension=dimension,
            metric=metric,
            spec=ServerlessSpec(cloud=cloud, region=region),
        )
        # Wait until the index is ready (it can take 30-60 seconds)
        import time
        while True:
            status = pc.describe_index(name).status["ready"]
            if status:
                break
            print("Waiting for the index to be ready...")
            time.sleep(2)
        print(f"Index '{name}' created")

    return pc.Index(name)


# Usage
pc = get_pinecone_client()
index = ensure_index(
    pc,
    name="production-rag",
    dimension=1536,    # text-embedding-3-small
    metric="cosine",
    cloud="aws",
    region="us-east-1",
)

Step 4: the smoke test

def smoke_test(index):
    """Verifies that the index responds correctly."""
    # The index's stats
    stats = index.describe_index_stats()
    print(f"Total vectors: {stats['total_vector_count']}")
    print(f"Dimension: {stats['dimension']}")
    print(f"Index fullness: {stats.get('index_fullness', 'N/A')}")

    # A test insert + delete
    test_vector = [0.1] * 1536  # a trivial vector
    index.upsert([("smoke_test_vector", test_vector, {"test": True})])
    print("Insert OK")

    # Query
    results = index.query(vector=test_vector, top_k=1, include_metadata=True)
    print(f"Query OK, results: {len(results['matches'])}")

    # Cleanup
    index.delete(ids=["smoke_test_vector"])
    print("Delete OK")


smoke_test(index)

Expected output:

Total vectors: 0
Dimension: 1536
Index fullness: 0
Insert OK
Query OK, results: 1
Delete OK

Validation: the dimension matches the embedding model

def validate_embedding_compatibility(index, embedding_function):
    """
    Generates a test embedding and verifies it matches the index's dimension.
    Critical before doing a bulk ingest.
    """
    test_text = "test"
    test_embedding = embedding_function(test_text)

    if isinstance(test_embedding, list):
        actual_dim = len(test_embedding)
    elif hasattr(test_embedding, "shape"):
        actual_dim = test_embedding.shape[0]
    else:
        raise TypeError(f"Embedding type unexpected: {type(test_embedding)}")

    index_dim = index.describe_index_stats()["dimension"]

    if actual_dim != index_dim:
        raise ValueError(
            f"Dimension mismatch! The embedding produces dim={actual_dim}, "
            f"the index expects dim={index_dim}. Recreate the index or change the model."
        )

    print(f"OK: the embedding ({actual_dim}d) is compatible with the index ({index_dim}d)")


# Use it before ingesting
from openai import OpenAI
openai_client = OpenAI()


def get_openai_embedding(text):
    response = openai_client.embeddings.create(
        input=text,
        model="text-embedding-3-small",
    )
    return response.data[0].embedding


validate_embedding_compatibility(index, get_openai_embedding)

Serverless vs pod-based

Pinecone offers two kinds of index:

AspectServerlessPod-based
CostPay per use (~$0.50 per 1M vectors/month + queries)A fixed cost per pod (~$70/month minimum)
ScalingAutomatic, no configurationManual, scale horizontally
Latency30-100ms10-50ms (optimized pods)
CasesGetting started, variable trafficLatency-critical, constant traffic

The initial recommendation: serverless. No commitments, pay for what you use. If latency or cost grow a lot later, evaluate pod-based.

# Serverless (recommended to start)
pc.create_index(
    name="my-rag",
    dimension=1536,
    metric="cosine",
    spec=ServerlessSpec(cloud="aws", region="us-east-1"),
)


# Pod-based (advanced, latency-critical)
from pinecone import PodSpec

pc.create_index(
    name="my-rag-pods",
    dimension=1536,
    metric="cosine",
    spec=PodSpec(
        environment="us-east-1-aws",
        pod_type="p1.x1",  # type and size
        pods=2,             # the number of pods
    ),
)

Configuration per environment

# config.py
import os
from typing import NamedTuple


class PineconeConfig(NamedTuple):
    api_key: str
    index_name: str
    dimension: int
    metric: str
    cloud: str
    region: str


def get_config(env: str = None) -> PineconeConfig:
    """Configuration separated by environment: dev, staging, prod."""
    env = env or os.getenv("APP_ENV", "dev")

    base_index = os.getenv("PINECONE_INDEX_BASENAME", "rag")

    return PineconeConfig(
        api_key=os.getenv("PINECONE_API_KEY"),
        index_name=f"{base_index}-{env}",  # rag-dev, rag-staging, rag-prod
        dimension=int(os.getenv("EMBEDDING_DIMENSION", "1536")),
        metric=os.getenv("PINECONE_METRIC", "cosine"),
        cloud=os.getenv("PINECONE_CLOUD", "aws"),
        region=os.getenv("PINECONE_REGION", "us-east-1"),
    )


# Usage
config = get_config()  # auto-detects APP_ENV
print(f"Using index: {config.index_name}")  # e.g. rag-prod

The benefits:

  • Dev/staging/prod completely separated (you don't contaminate the data).
  • Switch environments with an env var, without touching the code.
  • Versioned configuration (git) with no secrets (those live in .env).

Traps and common mistakes

Trap 1: the wrong dimension, with no validation

The mistake: creating an index with dimension=1536 (assumed) and then using 384-dim embeddings. The inserts fail.

The symptom: PineconeApiException: Vector dimension 384 does not match the dimension of the index 1536.

How to prevent it: run validate_embedding_compatibility before ingesting in bulk.

Trap 2: the wrong metric

The mistake: an index with metric="euclidean" when the embeddings expect cosine.

The symptom: the system works but the retrieval gives strange rankings. Cosine and euclidean produce similar rankings but NOT identical ones.

How to prevent it: use the metric the embedding model recommends (cosine for OpenAI, Cohere, ST).

Trap 3: an API key without sufficient permissions

The mistake: a "read-only" API key used to create indexes.

The symptom: 403 Forbidden on creation.

How to prevent it: create a specific API key with the "Manage Indexes" permission for the setup script. Afterwards you can use a lower-permission key for queries.

Trap 4: forgetting the time.sleep after creating

The mistake:

pc.create_index(...)
index = pc.Index(name)
index.upsert(...)  # fail! the index isn't ready yet

The symptom: a 503 error or a timeout.

How to prevent it: poll the status until ready=True before using it (as shown in ensure_index).

Trap 5: an index name with invalid characters

The mistake: pc.create_index(name="my_rag_index") (an underscore).

The symptom: Pinecone only allows lowercase + hyphens. my_rag_index fails.

How to prevent it: always use lowercase + -. my-rag-index

Trap 6: the same account for dev and prod

The mistake: dev and prod use the same API key and the same index.

The symptom: a dev runs a cleanup in dev and wipes production data.

How to prevent it: separate API keys, indexes with different names (rag-dev vs rag-prod).


Applied exercise

Scenario: you're migrating an existing RAG system to Pinecone. The details:

  • Current embedding model: OpenAI text-embedding-3-small (1536 dim)
  • The team is evaluating moving up to text-embedding-3-large (3072 dim) in the next 6 months
  • The app is deployed on GCP us-central1
  • 3 environments: dev, staging, prod
  • Multi-tenant with 50 customers

Your job:

  1. Configure the appropriate indexes (one or three? which dimension?).
  2. Design an idempotent setup script.
  3. A migration plan for the model change in 6 months.
Solution

1. The index configuration

The decision: 3 separate indexes (dev, staging, prod) with dimension 1536.

The reasons:

  • 3 separate environments is non-negotiable. Mixing them = the risk of wiping prod.
  • Dimension 1536 (the current model). For the future change to 3072, a separate plan.
  • Cloud GCP, region us-central1 (matching the app).
  • Metric cosine (recommended for OpenAI).
# config.py
INDICES_CONFIG = {
    "dev": {
        "name": "rag-dev",
        "dimension": 1536,
        "metric": "cosine",
        "cloud": "gcp",
        "region": "us-central1",
    },
    "staging": {
        "name": "rag-staging",
        "dimension": 1536,
        "metric": "cosine",
        "cloud": "gcp",
        "region": "us-central1",
    },
    "prod": {
        "name": "rag-prod",
        "dimension": 1536,
        "metric": "cosine",
        "cloud": "gcp",
        "region": "us-central1",
    },
}

2. The idempotent script

# scripts/setup_pinecone.py
"""
Idempotent setup of the Pinecone indexes.
Usage: APP_ENV=prod python scripts/setup_pinecone.py
"""
import os
import sys
import time
from pinecone import Pinecone, ServerlessSpec


def main():
    env = os.getenv("APP_ENV")
    if env not in ["dev", "staging", "prod"]:
        print("APP_ENV must be dev | staging | prod")
        sys.exit(1)

    # An extra guard: prod requires confirmation
    if env == "prod":
        confirm = input("⚠️  Modifying the PRODUCTION index. Continue? (yes/no): ")
        if confirm != "yes":
            print("Aborted.")
            sys.exit(0)

    config = INDICES_CONFIG[env]
    pc = Pinecone(api_key=os.getenv("PINECONE_API_KEY"))

    existing = [idx.name for idx in pc.list_indexes()]

    if config["name"] in existing:
        info = pc.describe_index(config["name"])
        # Validations
        assert info.dimension == config["dimension"], (
            f"Dim mismatch! Existing={info.dimension}, "
            f"config={config['dimension']}"
        )
        assert info.metric == config["metric"]
        print(f"OK: {config['name']} exists with the right config")
    else:
        print(f"Creating {config['name']}...")
        pc.create_index(
            name=config["name"],
            dimension=config["dimension"],
            metric=config["metric"],
            spec=ServerlessSpec(cloud=config["cloud"], region=config["region"]),
        )

        # Wait until ready
        while not pc.describe_index(config["name"]).status["ready"]:
            print("Waiting for ready...")
            time.sleep(3)
        print(f"✅ {config['name']} created")


if __name__ == "__main__":
    main()

3. The plan for migrating to text-embedding-3-large (3072 dim)

You can't simply change the dimension. The plan:

Phase 1 (1 week): create a new index with dim 3072

pc.create_index(
    name="rag-prod-v2",  # ← v2 signals the new dimension
    dimension=3072,
    metric="cosine",
    spec=ServerlessSpec(cloud="gcp", region="us-central1"),
)

Phase 2 (2 weeks): dual indexing

  • Every new doc gets ingested into BOTH indexes (with a 1536 embedding into the old one, 3072 into the new).
  • The extra cost during this phase: ~$200-400/month.

Phase 3 (1 week): backfill the new index

  • Re-embed all the old docs with text-embedding-3-large.
  • Cost: $50-200 depending on the volume.
  • Time: 2-3 days for 5M docs.

Phase 4 (1 week): A/B test

  • 10% of queries go to the new index, 90% to the old one.
  • Measure recall, precision, latency, costs.
  • If quality is +3% or better, it justifies the doubled cost (large is 6x more expensive than small for embedding generation).

Phase 5 (1 week): the full rollout

  • 50% → 100% to the new one.
  • Monitor for 1 week.
  • Decommission the old index.

The protective metrics:

  • Recall@5 must not drop (validate with the eval set).
  • p95 latency must not rise more than 20%.
  • The total cost must not exceed the budget.

Plan B if large doesn't justify itself:

  • Keep small. Decommission the new index.
  • Document the analysis for future decisions.

Summary and next step

What you learned:

  • Three immovable decisions when creating an index: dimension, metric, region/cloud.
  • The dimension must match your embedding model EXACTLY.
  • Cosine for 95% of RAG with text embeddings; change it only if the model recommends it.
  • An idempotent setup: an ensure_index that validates the existing config or creates a new one.
  • Pre-ingest validation: confirm the model's dimension matches the index's.
  • Serverless is the reasonable default; pod-based for latency-critical or constant-traffic cases.
  • Configuration per environment with separate indexes (rag-dev, rag-staging, rag-prod).
  • The traps: an API key with insufficient permissions, not waiting for the index to be ready, a name with an underscore, the same index for dev and prod.

Checkpoint: before moving on, you should be able to:

  • Create a Pinecone index with the right configuration.
  • Validate that the dimension matches the embedding model.
  • Implement an idempotent setup that can run in any environment.

Next capsule: 04 — The ChromaDB → Pinecone migration.

You have the index. Now comes the real migration: moving the data from ChromaDB without losing vectors or metadata, with no downtime for production, and with a rollback plan if something goes wrong.


Resources

  1. Pinecone — Quickstart — The official setup
  2. Pinecone — Create Index — The complete parameters
  3. Pinecone — Serverless vs Pods — The decision
  4. OpenAI Embeddings — Dimensions per model
  5. Pinecone — API Reference — The SDK in detail
  6. Pinecone Examples GitHub — Real code

Estimated time: 30-35 minutes Next: 04-migrating-from-chromadb-to-pinecone.md