Claude Code with Pinecone: Vector Search Without the Mistakes
Why Pinecone without CLAUDE.md ships broken retrieval
Pinecone is the most widely deployed managed vector database for RAG and semantic search workloads. The serverless tier removed the operational burden of capacity planning, the SDK is small, and a basic index is three API calls. The problem is that Claude Code does not know which of those three calls hide the decisions that determine whether your retrieval works at all. Without explicit constraints, Claude generates code that compiles, runs, and returns nearest neighbours that look plausible but are wrong in ways you only notice after a user complains.
The most common Claude defaults that break retrieval: creating an index with the wrong dimension (1536 for the legacy text-embedding-ada-002 when you actually use text-embedding-3-small at 1536 or text-embedding-3-large at 3072), defaulting to cosine similarity when your embedding model is normalised to euclidean, ignoring namespaces and shoving every tenant's data into the default namespace, upserting one vector at a time in a loop, and dropping metadata at upsert time because the SDK accepts the call without it. On top of those, Claude makes a consistent SDK-level mistake: it writes index.query({ vector, topK }) without the includeMetadata: true flag, then debugs for an hour why the result objects have no payload.
This guide covers the CLAUDE.md configuration that locks Claude Code into Pinecone's correct model: a serverless index sized for your embedding model, a namespace per tenant, batched upserts that respect the 2 MB request ceiling, metadata filters that scope queries, and hybrid search when keyword precision matters. For the embedding pipeline that feeds Pinecone, Claude Code with the OpenAI SDK covers the rate limiting and batching that keep embedding generation cheap. If your application is a Next.js app, Claude Code with Next.js shows the route handler patterns your retrieval calls will sit inside.
The Pinecone CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Pinecone integration it needs to declare: the SDK version, the environment variable names, the embedding model and its exact dimension, the index name and metric, the namespace strategy, the upsert batching rules, the metadata schema, and the hard rules that block the mistakes Claude makes most often.
# Pinecone rules
## Stack
- @pinecone-database/pinecone ^4.x, TypeScript 5.x strict
- Embedding model: text-embedding-3-small (1536 dimensions)
- Node.js 20.x (or Next.js 14.x route handlers / server actions)
- PINECONE_API_KEY in .env.local (never hardcode)
## Project structure
- src/lib/pinecone.ts , Pinecone client singleton + Index handle
- src/lib/embeddings.ts , OpenAI embedding wrapper with batching
- src/lib/retrieve.ts , Shared query function with metadata filters
- src/lib/ingest.ts , Shared upsert function with chunking
## Index configuration (DECLARED, not implicit)
- Index name: claudify-docs
- Dimension: 1536 (matches text-embedding-3-small EXACTLY)
- Metric: cosine
- Cloud: aws, region: us-east-1 (serverless)
- Pod type: serverless (NOT p1, NOT s1)
## Client initialisation
- ALWAYS use a singleton: import { pinecone, index } from '@/lib/pinecone'
- src/lib/pinecone.ts content:
import { Pinecone } from '@pinecone-database/pinecone';
export const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
export const index = pinecone.index('claudify-docs');
- NEVER instantiate new Pinecone() inline in route handlers
## Namespaces (MANDATORY per tenant or per content type)
- Single-tenant docs: 'docs'
- Per-user notes: 'user-{userId}'
- Per-org knowledge base: 'org-{orgId}'
- NEVER use the default empty namespace in production
- NEVER mix tenants in a shared namespace
## Upsert pattern (MANDATORY batching)
- Batch size: 100 vectors per upsert call
- Request ceiling: 2 MB (count metadata size, not just vectors)
- ALWAYS include id, values, and metadata fields
- ALWAYS pass namespace to .upsert()
- NEVER loop .upsert() one vector at a time
## Query pattern (MANDATORY flags)
- ALWAYS pass includeMetadata: true (the result is useless without it)
- ALWAYS pass namespace explicitly
- ALWAYS bound topK (typical range 5-20)
- NEVER omit filter when querying user-scoped or org-scoped namespaces
## Hard rules
- NEVER hardcode PINECONE_API_KEY in source files
- NEVER create an index with a dimension that does not match the embedding model
- NEVER mix embedding models in the same index
- NEVER upsert without a namespace
- NEVER query without includeMetadata: true
- NEVER pass topK greater than 100 without pagination
- ALWAYS verify PINECONE_API_KEY at startup, not at query time
Four rules here prevent the majority of broken retrievals Claude generates without them.
The dimension match rule prevents the most catastrophic failure. Pinecone indexes are created with a fixed vector dimension. If you create an index at 1536 dimensions and try to upsert 3072-dimension vectors, the SDK throws and the upsert fails. Worse, if you create the index at the wrong dimension to start with, every vector you upsert is silently low quality because the embedding model was trained for a different size. Claude defaults to 1536 because that is what appears in the legacy quickstart docs. If you have switched to text-embedding-3-large, you need 3072. Declaring the model and dimension together in CLAUDE.md makes the relationship explicit.
The namespace rule prevents tenant data leakage. Pinecone indexes are partitioned into namespaces at query time. A query against the default empty namespace returns vectors from every tenant in your account. Claude omits the namespace parameter because it is optional at the SDK level. In a multi-tenant SaaS this becomes a security incident the first time a user queries and sees results that belong to another customer. Declaring the namespace strategy in CLAUDE.md makes Claude generate the parameter on every call.
The batching rule prevents rate limit collisions and request size errors. Pinecone's upsert endpoint accepts up to 2 MB of payload per call and 100 vectors is the recommended batch size for text-embedding-3-small (1536 dimensions at 4 bytes each, plus metadata). Claude generates for (const doc of docs) { await index.upsert([vector]); } because that is the simplest pattern. At any scale this hits the rate limit and burns API time on overhead. The CLAUDE.md rule forces the correct batch shape.
The includeMetadata: true rule prevents a class of bug that wastes the most debugging time. Pinecone returns matches with id and score by default but omits metadata unless you ask. Claude generates index.query({ vector, topK: 5 }) and then the code tries to read match.metadata.text and gets undefined. The rule makes the flag explicit.
Install and API key setup
Install the Pinecone SDK:
npm i @pinecone-database/pinecone
Add the API key to your environment file. For Next.js:
# .env.local
PINECONE_API_KEY=pcsk_your_api_key_here
Create the singleton client and index handle that every retrieval call imports from:
// src/lib/pinecone.ts
import { Pinecone } from '@pinecone-database/pinecone';
if (!process.env.PINECONE_API_KEY) {
throw new Error('PINECONE_API_KEY is not defined');
}
export const pinecone = new Pinecone({
apiKey: process.env.PINECONE_API_KEY,
});
export const index = pinecone.index('claudify-docs');
The startup check surfaces a missing key immediately when the server boots rather than at the moment of the first query. Claude omits this check by default. Add it to your CLAUDE.md singleton example and Claude will generate it consistently.
Creating the index
A serverless index is the right default for nearly every project starting in 2026. It scales automatically, you pay for what you store and query, and there is no pod sizing to manage.
// scripts/create-index.ts
import { Pinecone } from '@pinecone-database/pinecone';
async function main() {
const pinecone = new Pinecone({
apiKey: process.env.PINECONE_API_KEY!,
});
await pinecone.createIndex({
name: 'claudify-docs',
dimension: 1536, // matches text-embedding-3-small
metric: 'cosine',
spec: {
serverless: {
cloud: 'aws',
region: 'us-east-1',
},
},
waitUntilReady: true,
});
console.log('Index created');
}
main().catch(console.error);
Run it once:
npx tsx scripts/create-index.ts
Add a hard rule to CLAUDE.md that names the embedding model and the dimension as a pair. Claude is more reliable when the relationship is declared, not inferred.
## Embedding model and dimension (LOCKED PAIR)
If embedding model is text-embedding-3-small : dimension MUST be 1536
If embedding model is text-embedding-3-large : dimension MUST be 3072
If embedding model is text-embedding-ada-002 (legacy) : dimension MUST be 1536
If embedding model is voyage-3 : dimension MUST be 1024
NEVER mix models in the same index. To switch models, create a new index and migrate.
The embedding pipeline
Pinecone does not generate embeddings. You generate them with an embedding model and upsert the resulting vectors. The OpenAI SDK is the default for most teams. The batching rule on the embedding call is as important as the batching rule on the upsert call.
// src/lib/embeddings.ts
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const EMBEDDING_MODEL = 'text-embedding-3-small';
const EMBEDDING_DIMENSION = 1536;
export async function embed(texts: string[]): Promise<number[][]> {
if (texts.length === 0) return [];
// OpenAI embedding API accepts up to 2048 inputs per call
const CHUNK_SIZE = 96;
const results: number[][] = [];
for (let i = 0; i < texts.length; i += CHUNK_SIZE) {
const chunk = texts.slice(i, i + CHUNK_SIZE);
const response = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: chunk,
});
for (const item of response.data) {
if (item.embedding.length !== EMBEDDING_DIMENSION) {
throw new Error(
`Embedding dimension mismatch: expected ${EMBEDDING_DIMENSION}, got ${item.embedding.length}`,
);
}
results.push(item.embedding);
}
}
return results;
}
The dimension check at the end of the loop catches the failure mode where someone changes the model name without updating the index. It throws once at ingest time instead of silently writing junk vectors that ruin retrieval forever. Claude does not write this check by default. The CLAUDE.md rule names the model and dimension as a pair, which makes the check natural to suggest.
Upserting vectors with metadata
The upsert call accepts an array of records. Each record has an id, a values array (the vector), and an optional metadata object. The metadata is what makes retrieval useful, it carries the source text, the document id, the tenant id, and any filter dimensions.
// src/lib/ingest.ts
import { index } from '@/lib/pinecone';
import { embed } from '@/lib/embeddings';
interface Document {
id: string;
text: string;
source: string;
userId: string;
createdAt: string;
}
const UPSERT_BATCH_SIZE = 100;
export async function ingestDocuments(docs: Document[], namespace: string) {
if (docs.length === 0) return;
// 1. Embed in one pass (the embed function chunks internally)
const vectors = await embed(docs.map((d) => d.text));
// 2. Build records with metadata
const records = docs.map((doc, i) => ({
id: doc.id,
values: vectors[i],
metadata: {
text: doc.text,
source: doc.source,
userId: doc.userId,
createdAt: doc.createdAt,
},
}));
// 3. Upsert in batches of 100
const ns = index.namespace(namespace);
for (let i = 0; i < records.length; i += UPSERT_BATCH_SIZE) {
const batch = records.slice(i, i + UPSERT_BATCH_SIZE);
await ns.upsert(batch);
}
console.log(`Ingested ${records.length} vectors into namespace ${namespace}`);
}
Three things in this function that Claude will not generate without CLAUDE.md rules: the dimension check inside embed(), the explicit index.namespace(namespace) handle, and the batching loop with the correct UPSERT_BATCH_SIZE. Each is a one-line change but together they prevent the three most common ingest failures.
Add the ingest pattern to CLAUDE.md as a concrete example:
## Ingest pattern (CONCRETE)
1. Take an array of documents
2. Embed all texts in one pass (the embedding function chunks internally)
3. Build records: { id, values, metadata }
4. Get namespace handle: const ns = index.namespace('docs')
5. Upsert in batches of 100: for (let i = 0; i < records.length; i += 100) { await ns.upsert(records.slice(i, i + 100)); }
Metadata MUST include the source text under a 'text' key. Without it, retrieval results cannot be shown to the model or the user.
Querying with metadata filters
The query call takes a vector, a topK, and optional metadata filter. The metadata filter is what scopes the query, you almost never want to retrieve from the entire namespace without scoping.
// src/lib/retrieve.ts
import { index } from '@/lib/pinecone';
import { embed } from '@/lib/embeddings';
interface RetrieveOptions {
query: string;
namespace: string;
userId?: string;
source?: string;
topK?: number;
}
interface RetrievalResult {
id: string;
score: number;
text: string;
source: string;
createdAt: string;
}
export async function retrieve(opts: RetrieveOptions): Promise<RetrievalResult[]> {
const { query, namespace, userId, source, topK = 8 } = opts;
// 1. Embed the query (single text, returns array of one vector)
const [vector] = await embed([query]);
// 2. Build metadata filter
const filter: Record<string, unknown> = {};
if (userId) filter.userId = { $eq: userId };
if (source) filter.source = { $eq: source };
// 3. Query with explicit namespace and includeMetadata
const ns = index.namespace(namespace);
const response = await ns.query({
vector,
topK,
includeMetadata: true,
filter: Object.keys(filter).length > 0 ? filter : undefined,
});
// 4. Map to typed results
return response.matches.map((match) => ({
id: match.id,
score: match.score ?? 0,
text: (match.metadata?.text as string) ?? '',
source: (match.metadata?.source as string) ?? '',
createdAt: (match.metadata?.createdAt as string) ?? '',
}));
}
Calling the function from a Next.js route handler:
// src/app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { retrieve } from '@/lib/retrieve';
import { auth } from '@/lib/auth';
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorised' }, { status: 401 });
}
const { query, source } = await req.json();
const results = await retrieve({
query,
namespace: `user-${session.user.id}`,
userId: session.user.id,
source,
topK: 8,
});
return NextResponse.json({ results });
}
The namespace is derived from the session, not from the request body. This pattern prevents a query parameter from being trusted to scope the search. Claude will pass the namespace from the request body unless CLAUDE.md states the rule.
## Query scoping (SECURITY)
- Namespaces and userId metadata filters MUST come from the session, never the request body
- A user-supplied namespace lets the user query any tenant's data
- NEVER pass req.json().namespace directly to ns.query()
- Always derive: namespace = `user-${session.user.id}` or `org-${session.user.orgId}`
Metadata filter syntax
Pinecone's filter language uses MongoDB-style operators. The common ones:
| Operator | Meaning | Example |
|---|---|---|
$eq |
Equals | { userId: { $eq: 'u_123' } } |
$ne |
Not equals | { source: { $ne: 'archived' } } |
$in |
One of | { tag: { $in: ['docs', 'guide'] } } |
$nin |
None of | { tag: { $nin: ['draft'] } } |
$gt |
Greater than (number) | { createdAt: { $gt: 1700000000 } } |
$gte |
Greater than or equal | { score: { $gte: 0.8 } } |
$lt |
Less than | { size: { $lt: 1000 } } |
$lte |
Less than or equal | { size: { $lte: 1000 } } |
$and |
All conditions match | { $and: [{ a: { $eq: 1 } }, { b: { $eq: 2 } }] } |
$or |
Any condition matches | { $or: [{ a: { $eq: 1 } }, { b: { $eq: 2 } }] } |
Combining filters:
const filter = {
$and: [
{ userId: { $eq: 'u_123' } },
{ source: { $in: ['docs', 'guide'] } },
{ createdAt: { $gte: oneMonthAgoUnix } },
],
};
Add the filter syntax to CLAUDE.md so Claude generates the correct operators. Without it, Claude reaches for SQL-style WHERE clauses or JavaScript-style equality that the Pinecone API rejects.
## Filter syntax (MongoDB-style operators)
- $eq, $ne, $in, $nin, $gt, $gte, $lt, $lte are the value operators
- $and, $or are the logical operators
- Filters apply to METADATA fields only, not vector values
- ALWAYS filter on indexed metadata keys, not on arbitrary nested objects
- Numbers compare numerically; strings compare exactly
- Booleans use $eq, not direct equality
Hybrid search with sparse vectors
Pinecone's hybrid search combines dense vector similarity with sparse keyword scores. It is the right default when the user query mixes natural language with specific terms that need exact matching, things like product SKUs, error codes, function names, or rare proper nouns.
// src/lib/hybrid-retrieve.ts
import { index } from '@/lib/pinecone';
import { embed } from '@/lib/embeddings';
import { buildSparseVector } from '@/lib/sparse';
interface SparseVector {
indices: number[];
values: number[];
}
export async function hybridRetrieve(query: string, namespace: string) {
const [dense] = await embed([query]);
const sparse: SparseVector = buildSparseVector(query);
// Alpha weights the balance: 1.0 = pure dense, 0.0 = pure sparse, 0.5 = balanced
const alpha = 0.7;
const ns = index.namespace(namespace);
const response = await ns.query({
vector: dense.map((v) => v * alpha),
sparseVector: {
indices: sparse.indices,
values: sparse.values.map((v) => v * (1 - alpha)),
},
topK: 10,
includeMetadata: true,
});
return response.matches;
}
Hybrid search requires an index created with the dotproduct metric instead of cosine. The metric is fixed at index creation time. If you start with cosine and later need hybrid, you have to create a new index. Declare the metric choice in CLAUDE.md so Claude does not silently default to cosine.
## Hybrid search (if needed)
- Hybrid search requires metric: 'dotproduct' at index creation time
- If you plan to use hybrid: create the index with dotproduct from the start
- The alpha parameter weights dense (semantic) vs sparse (keyword): typical 0.7
- buildSparseVector() converts text to a sparse vector via BM25-style scoring
- Use hybrid when queries mix natural language with exact terms (SKUs, codes, names)
- Pure dense (cosine metric) is the right default for most semantic search
Common Claude Code mistakes with Pinecone
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Dimension mismatch between embedding model and index
Claude generates: dimension: 1536 for an index that will store text-embedding-3-large vectors (3072 dimensions).
Correct pattern: declare the embedding model and dimension as a locked pair in CLAUDE.md, check both at runtime before the first upsert.
2. Missing namespace
Claude generates: await index.upsert(records) and await index.query({ vector }) with no namespace.
Correct pattern: every call goes through index.namespace(ns).upsert(...) and index.namespace(ns).query(...). The namespace is derived from the session, not the request body.
3. One vector per upsert call
Claude generates: for (const doc of docs) { await index.upsert([{ id: doc.id, values: vec, metadata }]); }.
Correct pattern: batch into arrays of 100, upsert each batch in one call.
4. includeMetadata omitted
Claude generates: await index.query({ vector, topK: 5 }) and then tries to read match.metadata.text.
Correct pattern: await index.query({ vector, topK: 5, includeMetadata: true }).
5. Filter on non-indexed fields
Claude generates: filters that reach into nested metadata objects or filter on fields that were never stored.
Correct pattern: flatten the metadata at upsert time. Store the fields you plan to filter on as top-level keys.
6. Inline client instantiation
Claude generates: const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY }) at the top of every file.
Correct pattern: one singleton at src/lib/pinecone.ts, imported everywhere.
Add a common mistakes section to CLAUDE.md with these six pairs. Claude benefits from explicit before/after comparisons because it can match the pattern it would otherwise generate to the corrected form.
Permission hooks for Pinecone scripts
A Pinecone project accumulates scripts: bulk reindex scripts, namespace migration scripts, index deletion scripts, suppression list maintenance. Some are read-only. Some destroy or move data at scale. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(npx tsx scripts/inspect-index.ts*)",
"Bash(npx tsx scripts/sample-query.ts*)",
"Bash(npx tsx scripts/list-namespaces.ts*)"
],
"deny": [
"Bash(npx tsx scripts/delete-index.ts*)",
"Bash(npx tsx scripts/reindex-all.ts*)",
"Bash(npx tsx scripts/clear-namespace.ts*)"
]
}
}
Inspecting an index, sampling queries, and listing namespaces are safe operations. Deleting an index, reindexing the entire corpus, or clearing a namespace are operations that need explicit confirmation. The deny list forces Claude to surface those operations as prompts rather than running them as part of an automated workflow. For background on the hook system, Claude Code with Supabase covers the same pattern applied to database migrations.
Building retrieval that actually retrieves
The Pinecone CLAUDE.md in this guide produces vector search integrations where the index dimension matches the embedding model, every call carries an explicit namespace, upserts batch correctly, queries include metadata, filters use the right operators, and the singleton client is used instead of inline instantiation.
The underlying principle is the same as any infrastructure integration with Claude Code. Pinecone without a CLAUDE.md produces code that looks correct and compiles cleanly but fails in ways that are hard to diagnose: nearest neighbours that are not actually nearest, tenants whose data leaks into each other, ingest jobs that hit rate limits and stall, and queries that return scores with no payload. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.
For the RAG orchestration layer that calls Pinecone, Claude Code with the OpenAI SDK and Claude Code with the Anthropic SDK cover the model-side patterns. Claudify includes a Pinecone-specific CLAUDE.md template with the dimension-model locked pair, namespace strategy, batched upsert pattern, metadata filter operators, hybrid search setup, and all six common-mistake rules pre-configured.
Get Claudify. Ship retrieval that retrieves the right thing on the first query.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify