Modern AI applications — semantic search, RAG pipelines, recommendation engines — need to store and query high-dimensional embeddings quickly. Pinecone pro
Modern AI applications — semantic search, RAG pipelines, recommendation engines — need to store and query high-dimensional embeddings quickly. Pinecone provides a managed vector database, but getting data into it reliably is non-trivial: embeddings must be generated with the same model that will be used for queries, metadata must be attached for filtering, and upserts need to be batched to stay within API rate limits and payload size caps. Doing this ad-hoc leads to half-populated indexes, dimension mismatches, and silent failures that only surface when a user's query returns garbage.
This script solves the ingestion side of that problem. It reads a newline-delimited JSON (JSONL) corpus, generates embeddings via OpenAI in batches, and upserts them into a Pinecone index with deterministic IDs and attached metadata. It is idempotent (re-running the same corpus overwrites rather than duplicates), resumable in spirit (deterministic IDs mean partial runs can be safely repeated), and safe under rate limits (exponential backoff on transient errors).
The script follows a straightforward ETL pipeline: Extract documents from a JSONL file, Transform each document's text into an embedding vector using OpenAI's text-embedding-3-small model, and Load the vectors plus metadata into a Pinecone index. The key design decisions are:
id field (or its text if no ID is supplied). This makes re-runs idempotent and prevents duplicate vectors.The script uses a small Config dataclass to centralize environment variables, and a PineconeUpserter class to encapsulate the pipeline. This makes the code testable and easy to extend.
#!/usr/bin/env python3
"""
Vector Database Upserter for Pinecone.
Reads a JSONL corpus, generates OpenAI embeddings for each document's text,
and upserts the resulting vectors plus metadata into a Pinecone index.
Typical usage:
python3 pinecone_upsert.py --index my-index --docs corpus.jsonl
"""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import os
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Iterator
from openai import OpenAI, APIError, RateLimitError
from pinecone import Pinecone, ServerlessSpec
from pinecone.exceptions import PineconeApiException
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Config:
"""Runtime configuration loaded from environment variables."""
openai_api_key: str
pinecone_api_key: str
embedding_model: str = "text-embedding-3-small"
embedding_dim: int = 1536
embed_batch_size: int = 100
upsert_batch_size: int = 100
max_retries: int = 5
backoff_base: float = 2.0
cloud: str = "aws"
region: str = "us-east-1"
@classmethod
def from_env(cls) -> "Config":
openai_key = os.environ.get("OPENAI_API_KEY")
pinecone_key = os.environ.get("PINECONE_API_KEY")
if not openai_key:
raise EnvironmentError("OPENAI_API_KEY is not set.")
if not pinecone_key:
raise EnvironmentError("PINECONE_API_KEY is not set.")
return cls(
openai_api_key=openai_key,
pinecone_api_key=pinecone_key,
embedding_model=os.environ.get(
"EMBEDDING_MODEL", "text-embedding-3-small"
),
embedding_dim=int(os.environ.get("EMBEDDING_DIM", "1536")),
embed_batch_size=int(os.environ.get("EMBED_BATCH_SIZE", "100")),
upsert_batch_size=int(os.environ.get("UPSERT_BATCH_SIZE", "100")),
max_retries=int(os.environ.get("MAX_RETRIES", "5")),
backoff_base=float(os.environ.get("BACKOFF_BASE", "2.0")),
cloud=os.environ.get("PINECONE_CLOUD", "aws"),
region=os.environ.get("PINECONE_REGION", "us-east-1"),
)
# ---------------------------------------------------------------------------
# Document loading
# ---------------------------------------------------------------------------
def load_documents(path: Path) -> list[dict[str, Any]]:
"""
Load a JSONL file into a list of dicts.
Each line must be a JSON object with a 'text' field (str). An optional
'id' field (str) is used for deterministic vector IDs; if absent, the
SHA-256 hash of 'text' is used. Any remaining keys become metadata.
"""
if not path.exists():
raise FileNotFoundError(f"Corpus file not found: {path}")
docs: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as fh:
for lineno, raw in enumerate(fh, start=1):
raw = raw.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON on line {lineno}: {exc}") from exc
if not isinstance(obj, dict) or "text" not in obj:
raise ValueError(
f"Line {lineno}: expected object with a 'text' field."
)
if not isinstance(obj["text"], str) or not obj["text"].strip():
raise ValueError(f"Line {lineno}: 'text' must be a non-empty string.")
docs.append(obj)
if not docs:
raise ValueError(f"No documents found in {path}")
return docs
def _stable_id(doc: dict[str, Any]) -> str:
"""Derive a deterministic vector ID from the document."""
seed = doc.get("id") or doc["text"]
return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:32]
def _metadata(doc: dict[str, Any]) -> dict[str, Any]:
"""Extract metadata (everything except 'text' and 'id')."""
return {k: v for k, v in doc.items() if k not in {"text", "id"}}
# ---------------------------------------------------------------------------
# Batching helpers
# ---------------------------------------------------------------------------
def _chunks(items: list[Any], size: int) -> Iterator[list[Any]]:
"""Yield successive chunks of `size` from `items`."""
for i in range(0, len(items), size):
yield items[i : i + size]
# ---------------------------------------------------------------------------
# Upserter
# ---------------------------------------------------------------------------
class PineconeUpserter:
"""End-to-end pipeline: JSONL -> embeddings -> Pinecone."""
def __init__(self, cfg: Config) -> None:
self.cfg = cfg
self.openai = OpenAI(api_key=cfg.openai_api_key)
self.pc = Pinecone(api_key=cfg.pinecone_api_key)
# -- index management ---------------------------------------------------
def ensure_index(self, name: str) -> None:
"""Create the index if it does not already exist."""
existing = {idx.name for idx in self.pc.list_indexes()}
if name in existing:
logging.info("Index '%s' already exists.", name)
return
logging.info(
"Creating index '%s' (dim=%d, metric=cosine).",
name,
self.cfg.embedding_dim,
)
self.pc.create_index(
name=name,
dimension=self.cfg.embedding_dim,
metric="cosine",
spec=ServerlessSpec(cloud=self.cfg.cloud, region=self.cfg.region),
)
# Wait for the index to become ready before returning.
while not self.pc.describe_index(name).status["ready"]:
time.sleep(2)
# -- embeddings ---------------------------------------------------------
def embed_batch(self, texts: list[str]) -> list[list[float]]:
"""Embed a batch of texts with retry/backoff."""
for attempt in range(1, self.cfg.max_retries + 1):
try:
resp = self.openai.embeddings.create(
model=self.cfg.embedding_model,
input=texts,
)
return [item.embedding for item in resp.data]
except RateLimitError:
wait = self.cfg.backoff_base ** attempt
logging.warning(
"OpenAI rate limit hit (attempt %d/%d). Sleeping %.1fs.",
attempt,
self.cfg.max_retries,
wait,
)
time.sleep(wait)
except APIError as exc:
if attempt == self.cfg.max_retries:
raise
wait = self.cfg.backoff_base ** attempt
logging.warning(
"OpenAI API error: %s. Retrying in %.1fs.", exc, wait
)
time.sleep(wait)
raise RuntimeError("Exhausted retries while embedding batch.")
# -- upsert -------------------------------------------------------------
def upsert_vectors(self, index_name: str, vectors: list[dict[str, Any]]) -> int:
"""Upsert vectors in batches with retry/backoff. Returns count upserted."""
index = self.pc.Index(index_name)
total = 0
for batch in _chunks(vectors, self.cfg.upsert_batch_size):
for attempt in range(1, self.cfg.max_retries + 1):
try:
index.upsert(vectors=batch)
total += len(batch)
break
except PineconeApiException as exc:
if attempt == self.cfg.max_retries:
raise
wait = self.cfg.backoff_base ** attempt
logging.warning(
"Pinecone upsert failed (%s). Retry %d/%d in %.1fs.",
exc,
attempt,
self.cfg.max_retries,
wait,
)
time.sleep(wait)
return total
# -- main pipeline ------------------------------------------------------
def run(self, index_name: str, docs: Iterable[dict[str, Any]]) -> int:
"""Full pipeline. Returns the number of vectors upserted."""
docs = list(docs)
logging.info("Loaded %d documents.", len(docs))
self.ensure_index(index_name)
all_vectors: list[dict[str, Any]] = []
for batch in _chunks(docs, self.cfg.embed_batch_size):
texts = [d["text"] for d in batch]
embeddings = self.embed_batch(texts)
# Validate the embedding dimension matches the index.
dim = len(embeddings[0])
if dim != self.cfg.embedding_dim:
raise ValueError(
f"Embedding dimension mismatch: got {dim}, "
f"expected {self.cfg.embedding_dim}. "
"Check EMBEDDING_MODEL / EMBEDDING_DIM."
)
for doc, vec in zip(batch, embeddings):
all_vectors.append(
{
"id": _stable_id(doc),
"values": vec,
"metadata": _metadata(doc),
}
)
logging.info("Embedded %d/%d documents.", len(all_vectors), len(docs))
upserted = self.upsert_vectors(index_name, all_vectors)
logging.info("Upserted %d vectors into '%s'.", upserted, index_name)
return upserted
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Upsert JSONL documents into a Pinecone index."
)
parser.add_argument(
"--index", required=True, help="Name of the Pinecone index."
)
parser.add_argument(
"--docs",
required=True,
type=Path,
help="Path to a JSONL corpus file.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Parse and embed but do not upsert.",
)
parser.add_argument(
"--verbose", "-v", action="store_true", help="Enable debug logging."
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
try:
cfg = Config.from_env()
docs = load_documents(args.docs)
if args.dry_run:
logging.info("Dry run: %d documents parsed successfully.", len(docs))
return 0
upserter = PineconeUpserter(cfg)
count = upserter.run(args.index, docs)
logging.info("Done. %d vectors upserted.", count)
return 0
except EnvironmentError as exc:
logging.error("Configuration error: %s", exc)
return 2
except FileNotFoundError as exc:
logging.error("File error: %s", exc)
return 3
except ValueError as exc:
logging.error("Data error: %s", exc)
return 4
except (APIError, PineconeApiException) as exc:
logging.error("Remote API error: %s", exc)
return 5
except KeyboardInterrupt:
logging.warning("Interrupted by user.")
return 130
except Exception as exc: # noqa: BLE001 - top-level safety net
logging.exception("Unexpected failure: %s", exc)
return 1
if __name__ == "__main__":
sys.exit(main())
The script reads configuration from environment variables, with sensible defaults. Set these before running:
| Variable | Required | Default | Purpose |
|---|---|---|---|
OPENAI_API_KEY |
Yes | — | Authenticates requests to OpenAI's embeddings endpoint. |
PINECONE_API_KEY |
Yes | — | Authenticates requests to Pinecone. |
EMBEDDING_MODEL |
No | text-embedding-3-small |
Must produce vectors of dimension EMBEDDING_DIM. |
EMBEDDING_DIM |
No | 1536 |
Dimension of vectors stored in the index. Change to 3072 for text-embedding-3-large. |
EMBED_BATCH_SIZE |
No | 100 |
Number of texts per OpenAI embedding request. |
UPSERT_BATCH_SIZE |
No | 100 |
Number of vectors per Pinecone upsert call. |
MAX_RETRIES |
No | 5 |
Retry attempts for transient API errors. |
BACKOFF_BASE |
No | 2.0 |
Base for exponential backoff (seconds). |
PINECONE_CLOUD |
No | aws |
Cloud for new serverless indexes. |
PINECONE_REGION |
No | us-east-1 |
Region for new serverless indexes. |
The JSONL corpus must contain one JSON object per line. Each object requires a text field; an optional id field controls the vector ID (otherwise derived from SHA-256 of the text). All other keys become vector metadata — keep them small (strings, numbers, booleans, or short lists), since Pinecone caps metadata at 40 KB per vector.
Example corpus.jsonl:
{"id": "doc-1", "text": "Pinecone is a managed vector database.", "source": "docs", "lang": "en"}
{"id": "doc-2", "text": "OpenAI provides embedding models.", "source": "blog", "lang": "en"}
1. Basic upsert into a new or existing index:
export OPENAI_API_KEY=sk-...
export PINECONE_API_KEY=pc-...
python3 pinecone_upsert.py --index my-index --docs corpus.jsonl
Expected output:
2025-01-15 10:12:01 [INFO] Loaded 250 documents.
2025-01-15 10:12:02 [INFO] Creating index 'my-index' (dim=1536, metric=cosine).
2025-01-15 10:12:18 [INFO] Embedded 100/250 documents.
2025-01-15 10:12:20 [INFO] Embedded 200/250 documents.
2025-01-15 10:12:22 [INFO] Embedded 250/250 documents.
2025-01-15 10:12:24 [INFO] Upserted 250 vectors into 'my-index'.
2025-01-15 10:12:24 [INFO] Done. 250 vectors upserted.
2. Dry run to validate the corpus without touching Pinecone:
python3 pinecone_upsert.py --index my-index --docs corpus.jsonl --dry-run
Output:
2025-01-15 10:15:00 [INFO] Dry run: 250 documents parsed successfully.
3. Verbose run with a custom embedding model:
EMBEDDING_MODEL=text-embedding-3-large EMBEDDING_DIM=3072 \
python3 pinecone_upsert.py --index big-index --docs corpus.jsonl -v
Config.from_env() raises EnvironmentError; main() catches it and exits with code 2.FileNotFoundError is caught and returns exit code 3.text field — load_documents() raises ValueError with the offending line number; exit code 4.ValueError and exits 4. This prevents polluting an index with wrong-shaped vectors.RateLimitError triggers exponential backoff up to MAX_RETRIES attempts, then propagates.APIError, caught in main() with exit code 5.PineconeApiException is retried per batch; final failure exits with code 5.130 (standard for SIGINT).1.Because vector IDs are deterministic hashes, a failed run can be safely retried from the beginning without creating duplicates — the same document always lands on the same ID.
concurrent.futures.ThreadPoolExecutor (or asyncio with the OpenAI async client) to overlap network round-trips. Keep batches independent so retries stay isolated.--namespace flag and pass it to index.upsert(..., namespace=...). Namespaces let you isolate tenants or document versions within a single index without re-creating it.last_modified timestamp in metadata and skip documents whose content hash has not changed. Combine with index.fetch(ids=[...]) to avoid re-embedding unchanged text.text-embedding-3-small), split into overlapping chunks, embed each, and store the parent document ID in metadata so a query can retrieve the full source.--filter flag that drops documents whose metadata does not match a predicate, so a single corpus can feed multiple indexes with different selective ingestion rules.