Skip to content
intermediate Phase 10 · Vector Databases in Depth

Managed Vectors with Pinecone

Use a hosted vector database with namespaces, metadata filtering, and autoscaling.

50m
0 problems
Topic Progress 0%

What the Managed Service Buys You

Two engineers run the support assistant for a product with 40 tenants. Postgres holds tickets, Redis holds sessions, and the retrieval layer needs 40 million ticket embeddings that are queried on every agent keystroke and rewritten as new tickets arrive. Nobody on the team wants to carry the pager for vector replication, backup verification, or a shard rebalance at 3 a.m. — so the question Pinecone answers is not how do I store vectors but which operational work disappears.

What disappears: capacity planning, replica placement, backup and restore, index-type tuning, and horizontal resharding. What replaces it: an API with three moving parts — an index, a namespace, and a batched upsert.

import os
from pinecone import Pinecone, ServerlessSpec

pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])

if "support" not in pc.list_indexes().names():
    pc.create_index(
        name="support",
        dimension=1536,
        metric="cosine",
        spec=ServerlessSpec(cloud="aws", region="us-east-1"),
    )

index = pc.Index("support")
print(index.describe_index_stats())

Output

{'dimension': 1536,
 'index_fullness': 0.0,
 'namespaces': {'tickets': {'vector_count': 40000}},
 'total_vector_count': 40000}

dimension and metric are fixed when the index is created: changing the embedding model means a new index, which is exactly what the migration runbook in Index and Collection Operations is for. index_fullness and the per-namespace counts are the two numbers worth graphing over time.

Writes go in batches, never one vector at a time:

def batches(rows, size=100):          # serverless upserts cap at 100 vectors per request
    for i in range(0, len(rows), size):
        yield rows[i:i + size]

vectors = [
    {"id": f"{tenant}:{ticket_id}",        # tenant in the id: exports stay self-describing
     "values": emb,
     "metadata": {"tenant": tenant, "status": "open", "priority": 2}}
    for tenant, ticket_id, emb in rows
]

for chunk in batches(vectors):
    index.upsert(vectors=chunk, namespace="tickets")

Each upsert is idempotent on id: the same id twice overwrites, so a retried batch cannot duplicate data — but a reused id also silently replaces a vector, a failure mode worth remembering. The two billing models differ in what you are charged for:

Serverless Pod-based
Unit of charge Read/write units plus storage Pod-hours plus storage
Capacity planning None: throughput follows request volume Choose pod size and replica count up front
Scales to Traffic spikes without intervention The pods you provisioned
When it wins Variable or spiky traffic, most new projects Steady high throughput with predictable cost per hour

For a workload that spikes when a shift changes over, serverless removes the sizing decision entirely — the trade is that cost tracks request volume closely, which is the second half of this topic.

Namespaces Draw the Tenant Line

One index, forty tenants: the design choice that decides whether tenant isolation is enforced by the data layer or hoped for in application code. A namespace is a partition inside an index — same dimension, same metric, same bill — and a query always reads exactly one of them.

index: support  (dimension 1536, metric cosine)
├── namespace "tickets"        40,000 vectors   <- writes land here
├── namespace "acme"           1,240,000 vectors
├── namespace "globex"           980,000 vectors
└── namespace "" (default)            12 vectors <- surprise: unscoped writes

The empty default namespace on the last line is how tenant leaks start: an upsert that forgets namespace= writes into "", and a query that forgets it reads "". Both succeed silently.

Scoping is a two-line discipline:

index.upsert(vectors=chunk, namespace=f"tenant:{tenant}")

res = index.query(
    vector=query_embedding,
    top_k=5,
    namespace=f"tenant:{tenant}",           # read side of the same boundary
    filter={"status": {"$in": ["open", "pending"]}},
    include_metadata=True,
)

Input: tenant acme, query vector for duplicate charge on invoice 88

Output

id             score  status  priority
acme:1041      0.873  open    2
acme:1188      0.861  pending 1
acme:0977      0.844  open    3

Offboarding a tenant becomes one call — index.delete(delete_all=True, namespace="tenant:acme") — instead of a filter-matching sweep whose correctness you have to argue for. Three consequences follow from the namespace-is-a-hard-boundary model:

  • No cross-tenant query exists. Anything global (product-wide trending, a fusion step like Reciprocal Rank Fusion across sources) must fan out over namespaces and merge results in the application. Budget the fan-out explicitly; forty namespaces means forty queries per global search.
  • Metadata still travels with the vector. Namespaces cut the search space; metadata (status, priority, visibility) cuts within it. Use both — namespace for who, filter for what.
  • Counts are the leak detector. describe_index_stats() prints vector counts per namespace nightly in a scheduled job; a namespace that grows when a test suite runs, or an unexpected "", is a bug found in minutes instead of in an incident review.

The pairing that fails in practice is one flat namespace plus a tenant metadata filter. It works — until a filter is dropped from one query out of forty, and the only thing standing between that mistake and another tenant's data is a Python argument nobody reviewed.

Filters That Enforce Permissions and the Bill That Follows

A free-tier user should not retrieve your customers' private documents, and a revoked document should stop appearing the moment it is revoked. Both are properties of where the permission check runs. Pinecone applies metadata filters server-side, inside the ranked query, so authorization cannot be forgotten at a call site:

res = index.query(
    vector=q,
    top_k=10,
    namespace="kb",
    filter={
        "visibility": {"$in": ["public"]},          # free tier: public only
        "deleted": {"$ne": True},                     # soft-deleted rows stay out
    },
    include_metadata=True,
)
print(res["usage"]["read_units"])

Input: query vector from a free-tier account

Output: three matches from visibility: public, and read_units: 1

read_units is the meter: a query's cost tracks the work it does, so a filter that narrows a huge namespace before ranking is cheaper than a wide scan you post-filter in Python. Revocation works by metadata update — upserting the same id with deleted: true — because re-running a query is free of stale results once the filter runs server-side. Metadata Filtering covers the operator set ($eq, $in, $ne, numeric and date ranges).

Now the write side of the meter. The 40,000-vectors-per-hour backfill from the first chapter looks identical from the product's view under two very different request patterns:

Pattern Requests per hour What you pay for
One vector per request 40,000 Request overhead 40,000 times, same data moved
Batched at 100 (the serverless cap) 400 One overhead per batch, retries resurface 100 vectors not 1

The chatty pattern does not just cost more: it multiplies the blast radius of a retry, and it is the pattern most first drafts ship because for row in rows: index.upsert(...) reads clearly. The failure modes to design against:

  • Filter matches nothing. An empty result is a success response, not an error — a typo'd metadata key (status written as Status) returns zero matches forever. Assert non-empty on a canary query after any metadata schema change.
  • Namespace typo on write. Data lands in a new namespace; reads still return the old set. The nightly describe_index_stats() count check catches it.
  • Reused vector ids. Upsert overwrites silently, so a regeneration job that keeps ids can clobber newer embeddings with older ones. Version the id or gate regeneration on an embedding checksum.
  • Post-filtering in Python. Pulling 100 vectors and filtering to 3 wastes read units on rows you were never allowed to show.

When the write volume outgrows this pattern or retention rules start demanding bulk deletes, the operations side is covered in Index and Collection Operations, and the recall cost of the index Pinecone picked for you is covered by Approximate Nearest Neighbor Indexes.

Quiz

1. An upsert runs with no `namespace` argument while every query uses `namespace="tenant:acme"`. What happens?

Question 1 options

2. Why is a permission rule expressed as a metadata filter stronger than one applied in Python after the query returns?

Question 2 options

3. A backfill writes 40,000 vectors with one `index.upsert` call per vector. What is the primary consequence?

Question 3 options

4. After changing a metadata schema so `visibility` replaces `access_level`, a canary query returns zero matches. What does that indicate?

Question 4 options

Flashcards

Question

What does creating a Pinecone index fix permanently?

Answer

Name, dimension, metric (cosine/dotproduct/euclidean), cloud and region. Changing the embedding model's dimension means a new index and a migration, not an ALTER.

Question

Namespace vs metadata filter — what does each one cut?

Answer

Namespace partitions the index (who: tenant, environment) and bounds what a query can read at all; metadata filters (status, visibility, dates) narrow within that partition. Use namespace for tenancy, filter for attributes.

Question

What are upsert semantics on an existing id?

Answer

Idempotent overwrite: same id twice keeps one vector, which makes retries safe but lets a stale regeneration clobber a newer embedding. Version the id or checksum the embedding when order matters.

Question

Why batch upserts on a serverless index?

Answer

Serverless upserts cap at 100 vectors per request; batching converts per-request overhead from N requests to N/100, and keeps retries cheap. Chatty single-vector writes dominate the bill at backfill volume.

Question

Which two numbers from describe_index_stats are worth alerting on?

Answer

total_vector_count (or per-namespace counts) and index_fullness. A namespace that grows during tests or an unexpected default namespace shows up in the counts before it shows up in bad retrieval results.

Revision Notes

Key Takeaways

  • 1. Managed means the operational surface (replication, backups, resharding, sizing) is replaced by an API contract you cannot tune
  • 2. Namespaces are the tenancy boundary: a query reads exactly one, and an omitted namespace silently means the default
  • 3. Permission rules belong in the server-side metadata filter, where a forgotten check has nothing to forget
  • 4. Upsert is idempotent on id — retry-safe, and equally capable of clobbering newer data with older ids
  • 5. Write cost follows request count, so batching to the 100-vector cap is the cheapest available change

Interview Tips

  • • Be ready to say what you gave up by going managed: no index-type choice, no direct introspection of the graph, and cost that scales with request volume
  • • Explain the namespace-plus-filter model in one sentence and name the failure it prevents (one dropped tenant argument leaking cross-tenant data)
  • • Describe how you would prove isolation: per-namespace counts, a canary query per tenant, and a deliberate cross-tenant attempt that must return nothing
  • • Expect a cost question — walk through batching at the per-request cap and the read-unit effect of narrowing filters

Cheat Sheet

Pinecone — Cheat Sheet

Provision

from pinecone import Pinecone, ServerlessSpec
pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
if "support" not in pc.list_indexes().names():
    pc.create_index(name="support", dimension=1536, metric="cosine",
                    spec=ServerlessSpec(cloud="aws", region="us-east-1"))
index = pc.Index("support")
index.describe_index_stats()   # dimension, index_fullness, per-namespace counts

Batched write (100 vectors per request)

def batches(rows, size=100):
    for i in range(0, len(rows), size):
        yield rows[i:i + size]

vectors = [{"id": f"{tenant}:{tid}", "values": emb,
            "metadata": {"tenant": tenant, "status": "open"}}
           for tenant, tid, emb in rows]
for chunk in batches(vectors):
    index.upsert(vectors=chunk, namespace=f"tenant:{tenant}")

Scoped query

res = index.query(vector=q, top_k=10, namespace=f"tenant:{tenant}",
                  filter={"status": {"$in": ["open", "pending"]},
                          "deleted": {"$ne": True}},
                  include_metadata=True)
res["usage"]["read_units"]      # the meter

Offboarding / cleanup

index.delete(ids=[...], namespace="tenant:acme")
index.delete(delete_all=True, namespace="tenant:acme")

Checklist before shipping a metadata change

  1. Canary query with the new key — assert non-empty
  2. Backfill the new key on existing ids (upsert overwrites)
  3. Nightly alert on per-namespace vector counts
  4. Every upsert and every query names its namespace explicitly

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading