Build a Q&A Bot over Your Company Docs with Pinecone + Claude (Python, SDK v10)
AI disclosure: This article was written with the help of AI and reviewed by me. Every API call in it was checked against the SDK source of pinecone==10.3.0 and anthropic==2.4.0 , and prices and limits were taken from the official pages linked inline (as of November 2026). Treat the code as a starti

AI disclosure: This article was written with the help of AI and reviewed by me. Every API call in it was checked against the SDK source of pinecone==10.3.0 and anthropic==2.4.0, and prices and limits were taken from the official pages linked inline (as of November 2026). Treat the code as a starting point: run it against your own keys before relying on it.
Once a company has enough internal docs, the same problems show up: new hires can't find the leave policy, nobody remembers which version of the expense rules is current, and wiki search only works if you guess the exact words the author used ("PTO" won't find the page that says "annual leave").
Retrieval-augmented generation (RAG) is the usual fix: split documents into chunks, embed them into a vector database, retrieve the most relevant chunks for each question, and have an LLM answer only from those chunks, with citations.
This walkthrough builds a command-line Q&A bot with Pinecone (managed vector database with hosted embedding and rerank models) and Claude (Anthropic's LLM, for answer generation):
Versions used (latest on PyPI in November 2026):
The Pinecone SDK changed a lot in v10. Many tutorials still use the v5–v7 style ( create_index_for_model, upsert_records, ServerlessSpec ), which may fail or emit deprecation warnings. The pitfalls section maps old calls to new ones.
┌──────────── Offline ingest ─────────────────┐.md/.txt ──> chunk ──> documents.upsert ──> Pinecone index │ (llama-text-embed-v2 │ embeds on write) └─────────────────────────────────────────────┘ ┌──────────── Online ask ─────────────────────┐ question ──> documents.search (embed scoring, recall 12) ──> inference.rerank (bge-reranker-v2-m3, keep 4) ──> build prompt ──> Claude Messages API ──> answer with [n] citations └─────────────────────────────────────────────┘ Enter fullscreen mode Exit fullscreen mode Embedding, storage and reranking all happen on Pinecone's side, so you don't install PyTorch or host an embedding model. The trade-off: your text is processed by Pinecone's hosted models (more on that under data residency).
Create an account at pinecone.io, then create a key on the API Keys page of the console ( https://app.pinecone.io ). The free Starter plan is enough for everything here, but note its limits (as of November 2026, see Pricing and Rate limits ):
Create a key in the Claude Console ( https://platform.claude.com ). Check Anthropic's supported countries list for your region first.
pinecone==10.3.0 anthropic==2.4.0 Enter fullscreen mode Exit fullscreen mode python -m venv.venv source.venv/bin/activate pip install -r requirements.txt Enter fullscreen mode Exit fullscreen mode The package is pinecone, not the old pinecone-client.
export PINECONE_API_KEY = "your-pinecone-key" export ANTHROPIC_API_KEY = "your-anthropic-key" Enter fullscreen mode Exit fullscreen mode Don't hard-code keys or commit them to Git.
Chunking is the first thing that makes or breaks RAG quality. Chunks that are too large mix several topics, so the embedding gets "averaged out" and you waste prompt tokens. Chunks that are too small lose context and the model gets half a sentence.
A simple strategy that works for Markdown or plain-text policy docs:
def split_text ( text: str, size: int = 500, overlap: int = 80 ) -> list [ str ]: paragraphs = [ p. strip () for p in text. split ( " \n\n " ) if p. strip ()] chunks, buf = [], "" for p in paragraphs: if len ( p ) > size: if buf: chunks. append ( buf ) buf = "" step = size - overlap chunks. extend ( p [ i: i + size ] for i in range ( 0, len ( p ), step )) continue if len ( buf ) + len ( p ) + 1 <= size: buf = f " { buf } \n { p } " if buf else p else: chunks. append ( buf ) buf = ( buf [ - overlap:] + " \n " + p ) if overlap else p if buf: chunks. append ( buf ) return chunks Enter fullscreen mode Exit fullscreen mode This counts characters, not tokens, to avoid depending on a tokenizer. 500 characters of English is roughly 100–150 tokens, well under both limits that matter here:
For finer strategies (by sentence, by token, by heading), see Pinecone's Chunking Strategies.
Each chunk carries metadata: source (relative path), category (top-level folder such as hr/ or finance/ ) and chunk_index. category is used for filtering, source for citations and for cleaning up on re-ingest.
Anthropic doesn't offer an embedding model ( Embeddings ), so you need one from somewhere else. Three common options:
Pinecone-hosted dense embedding models (docs as of November 2026):
On the Standard plan, llama-text-embed-v2 is listed at $0.16 per million tokens and multilingual-e5-large at $0.08 ( Pricing ).
This walkthrough uses llama-text-embed-v2: multilingual, a roomier input limit than e5, and available on Starter. If your docs are mostly short Q&A pairs, try multilingual-e5-large: change the model name in the schema and rebuild the index.
In SDK v10 the recommended index type is the document index. You declare the scoring fields with SchemaBuilder; every other field you write is stored as metadata and indexed for filtering automatically. Adding an embed config to a string field makes Pinecone embed that field's text on write, and embed the query text at search time ( Integrated embedding ).
import os from pinecone import Pinecone, SchemaBuilder pc = Pinecone ( api_key = os. environ [ " PINECONE_API_KEY " ]) schema = ( SchemaBuilder (). add_string_field ( name = " chunk_text ", embed = { " model ": " llama-text-embed-v2 ", " dimension ": 1024 }, ). build () ) if not pc. indexes. exists ( name = " company-docs " ): pc. indexes. create ( name = " company-docs ", schema = schema, deployment = { " deployment_type ": " managed ", " cloud ": " aws ", " region ": " us-east-1 " }, ) Enter fullscreen mode Exit fullscreen mode Notes:
index = pc. index ( name = " company-docs " ) documents = [ { " _id ": " 3be9998a6b0d7b83#0 ", # ASCII only " chunk_text ": " Employees get 5 days of paid annual leave after one year of service... ", " source ": " hr/annual-leave.md ", # metadata, filterable automatically " category ": " hr ", " chunk_index ": 0, }, #... ] for i in range ( 0, len ( documents ), 96 ): index. documents. upsert ( namespace = " default-tenant ", documents = documents [ i: i + 96 ]) Enter fullscreen mode Exit fullscreen mode Key points:
Re-ingesting: if a file shrinks and produces fewer chunks, the old extra chunks linger. The script calls documents.delete(filter=...) by source before writing, to clear a file's old chunks.
resp = index. documents. search ( namespace = " default-tenant ", top_k = 12, score_by = [{ " type ": " embed ", " fields ": [ " chunk_text " ], " query ": " How much annual leave do I get? " }], include_fields = [ " chunk_text ", " source ", " chunk_index " ], filter = { " category ": { " $eq ": " hr " }}, # optional metadata filter ) for m in resp. matches: print ( m. id, m. score, m. get ( " source " )) Enter fullscreen mode Exit fullscreen mode With embed scoring, Pinecone embeds the query with the model fixed at index creation, so write and query always use the same model and dimension. Without include_fields you only get _id and _score.
This is a classic beginner trap: the search "works" but returns no text. ["*"] returns every field. An embed clause must appear alone in score_by; it can't be combined with a BM25 text clause. For hybrid search, the docs suggest running both searches and merging with RRF ( Integrated embedding ).
7.2 Why rerank Vector recall uses a bi-encoder: question and documents are embedded separately and compared by distance. It's fast but coarse. A rerank model (cross-encoder) scores the question and each document together, which is more accurate but slower.
The usual pattern is to over-recall with vectors (12 here) and rerank down to the best few (4 here) for the prompt.
ranked = pc. inference. rerank ( model = " bge-reranker-v2-m3 ", query = " How much annual leave do I get? ", documents = [{ " id ": m. id, " chunk_text ": m. get ( " chunk_text ", "" )} for m in resp. matches ], rank_fields = [ " chunk_text " ], top_n = 4, return_documents = False, parameters = { " truncate ": " END " }, ) for r in ranked. data: print ( r. index, r. score ) # r.index is the position in the documents you passed in Enter fullscreen mode Exit fullscreen mode Model choice: Pinecone currently hosts three rerank models: bge-reranker-v2-m3 (multilingual, available on Starter), cohere-rerank-4-fast (Standard and above, processed via Azure, possibly outside the US), and pinecone-rerank-v0 (deprecated; don't use it in new projects).
bge-reranker-v2-m3 defaults to truncate: NONE, which errors on over-long input, so END is set explicitly.
Older tutorials often do recall + rerank in one call via index.search(..., rerank={...}). That's for legacy integrated indexes (created with create_index_for_model, using the Records API). With document indexes, rerank is a separate pc.inference.rerank call.
Wrap each retrieved chunk in numbered XML tags so the model has anchors to cite:
SYSTEM_PROMPT = """ You are an internal knowledge-base assistant. Answer employees ' questions using ONLY the material inside <documents>. Rules: 1.
If the answer isn ' t in the material, say " I couldn ' t find this in the knowledge base. " Don ' t make anything up. 2.
After each key statement, cite the source as [n], where n matches <document index= " n " >. 3. If sources conflict, list the conflict instead of picking one.
4. Answer concisely. """ def build_user_message ( question, hits ): blocks = " \n ". join ( f ' <document index= " { i } " source= " { h [ " source " ] } " > \n { h [ " text " ] } \n </document> ' for i, h in enumerate ( hits, start = 1 ) ) return f " <documents> \n { blocks } \n </documents> \n\n Question: { question } " Enter fullscreen mode Exit fullscreen mode Calling the Messages API:
import anthropic client = anthropic. Anthropic () # reads ANTHROPIC_API_KEY response = client. beta. messages. create ( model = " claude-sonnet-5-5 ", max_tokens = 16000, system = SYSTEM_PROMPT, messages = [{ " role ": " user ", " content ": build_user_message ( question, hits )}], output_config = { " effort ": " medium " }, fallbacks = " default ", betas = [ " server-side-fallback-2026-07-01 " ], ) if response. stop_reason == " refusal ": print ( " The model declined to answer. " ) else: print ( "". join ( b. text for b in response. content if b. type == " text " )) Enter fullscreen mode Exit fullscreen mode What's different from older code:
""" rag_bot.py - company-docs Q&A bot on Pinecone (integrated embedding + rerank) + Claude Usage: python rag_bot.py ingest./docs # chunk and upsert python rag_bot.py ask " How much annual leave do I get? " python rag_bot.py ask " expense process " --category finance # search one category only Versions at time of writing: pinecone==10.3.0, anthropic==2.4.0, python>=3.10 """ import argparse import hashlib import os import sys import time from pathlib import Path import anthropic from pinecone import Pinecone, SchemaBuilder from pinecone.errors import NotFoundError # ---------- config ---------- INDEX_NAME = os. environ. get ( " PINECONE_INDEX ", " company-docs " ) NAMESPACE = os. environ. get ( " PINECONE_NAMESPACE ", " default-tenant " ) EMBED_MODEL = " llama-text-embed-v2 " # Pinecone-hosted embedding model EMBED_DIM = 1024 # this model supports 384/512/768/1024/2048 RERANK_MODEL = " bge-reranker-v2-m3 " # Pinecone-hosted rerank model (multilingual) USE_RERANK = os. environ. get ( " USE_RERANK ", " 1 " ) == " 1 " CLAUDE_MODEL = " claude-sonnet-5-5 " CHUNK_SIZE = 500 # target characters per chunk CHUNK_OVERLAP = 80 # overlap between adjacent chunks UPSERT_BATCH = 96 # max docs per upsert for indexes with an embed field RECALL_TOP_K = 12 # vector recall size FINAL_TOP_N = 4 # chunks sent to the prompt after rerank pc = Pinecone ( api_key = os. environ [ " PINECONE_API_KEY " ]) claude = anthropic. Anthropic () # reads ANTHROPIC_API_KEY # ---------- 1. chunking ---------- def split_text ( text: str, size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP ) -> list [ str ]: """ Split on blank lines, pack paragraphs up to `size`, hard-split long paragraphs with overlap.
""" paragraphs = [ p. strip () for p in text. split ( " \n\n " ) if p. strip ()] chunks, buf = [], "" for p in paragraphs: if len ( p ) > size: # long paragraph: flush buffer, then sliding window if buf: chunks. append ( buf ) buf = "" step = size - overlap chunks. extend ( p [ i: i + size ] for i in range ( 0, len ( p ), step )) continue if len ( buf ) + len ( p ) + 1 <= size: buf = f " { buf } \n { p } " if buf else p else: chunks. append ( buf ) # carry the tail of the previous chunk to reduce answers cut in half buf = ( buf [ - overlap:] + " \n " + p ) if overlap else p if buf: chunks. append ( buf ) return chunks def load_documents ( root: Path ) -> list [ dict ]: """ Read.md /.txt files under root and build the documents to upsert. """ docs = [] for path in sorted ( root. rglob ( " * " )): if path. suffix. lower () not in { ".md ", ".txt " } or not path. is_file (): continue rel = path. relative_to ( root ). as_posix () text = path. read_text ( encoding = " utf-8 " ) # _id must be ASCII; hash the path so non-ASCII file names work file_key = hashlib. sha1 ( rel. encode ( " utf-8 " )). hexdigest ()[: 16 ] category = rel. split ( " / " )[ 0 ] if " / " in rel else " general " for i, chunk in enumerate ( split_text ( text )): docs. append ({ " _id ": f " { file_key } # { i } ", " chunk_text ": chunk, # Pinecone embeds this field " source ": rel, # the rest is metadata, filterable automatically " category ": category, " chunk_index ": i, }) return docs # ---------- 2. index ---------- def ensure_index () -> None: if pc. indexes. exists ( name = INDEX_NAME ): return schema = ( SchemaBuilder (). add_string_field ( name = " chunk_text ", embed = { " model ": EMBED_MODEL, " dimension ": EMBED_DIM }, ). build () ) print ( f " Creating index { INDEX_NAME }... " ) pc. indexes. create ( name = INDEX_NAME, schema = schema, # managed = serverless.
The free Starter plan only allows aws / us-east-1 deployment = { " deployment_type ": " managed ", " cloud ": " aws ", " region ": " us-east-1 " }, ) # blocks until the index is ready by default # ---------- 3. ingest ---------- def ingest ( root: Path ) -> None: ensure_index () index = pc. index ( name = INDEX_NAME ) docs = load_documents ( root ) if not docs: print ( " No.md /.txt files found " ) return # on re-ingest, delete each file's old chunks first so none are left behind for source in sorted ({ d [ " source " ] for d in docs }): try: index. documents. delete ( namespace = NAMESPACE, filter = { " source ": { " $eq ": source }}) except NotFoundError: pass # namespace doesn't exist yet (first ingest) for i in range ( 0, len ( docs ), UPSERT_BATCH ): batch = docs [ i: i + UPSERT_BATCH ] resp = index. documents. upsert ( namespace = NAMESPACE, documents = batch ) tokens = resp. usage. embed_total_tokens if resp. usage else "? " print ( f " Upserted { i + len ( batch ) } / { len ( docs ) } chunks, embedding tokens this batch: { tokens } " ) time. sleep ( 5 ) # indexing is asynchronous; wait a moment before querying print ( " Ingest complete " ) # ---------- 4. retrieve + rerank ---------- def retrieve ( question: str, category: str | None = None ) -> list [ dict ]: index = pc. index ( name = INDEX_NAME ) resp = index. documents. search ( namespace = NAMESPACE, top_k = RECALL_TOP_K, score_by = [{ " type ": " embed ", " fields ": [ " chunk_text " ], " query ": question }], include_fields = [ " chunk_text ", " source ", " chunk_index " ], filter = { " category ": { " $eq ": category }} if category else None, ) hits = [ { " id ": m. id, " score ": m. score, " text ": m. get ( " chunk_text ", "" ), " source ": m. get ( " source ", "" ), } for m in resp. matches ] if not hits or not USE_RERANK: return hits [: FINAL_TOP_N ] ranked = pc. inference. rerank ( model = RERANK_MODEL, query = question, documents = [{ " id ": h [ " id " ], " chunk_text ": h [ " text " ]} for h in hits ], rank_fields = [ " chunk_text " ], top_n = FINAL_TOP_N, return_documents = False, parameters = { " truncate ": " END " }, ) # r.index is the document's position in the list we passed in return [ dict ( hits [ r. index ], score = r. score ) for r in ranked. data ] # ---------- 5. prompt Claude ---------- SYSTEM_PROMPT = """ You are an internal knowledge-base assistant. Answer employees ' questions using ONLY the material inside <documents>.
Rules: 1. If the answer isn ' t in the material, say " I couldn ' t find this in the knowledge base. " Don ' t make anything up.
2. After each key statement, cite the source as [n], where n matches <document index= " n " >. 3.
If sources conflict, list the conflict instead of picking one. 4. Answer concisely.
""" def build_user_message ( question: str, hits: list [ dict ]) -> str: blocks = " \n ". join ( f ' <document index= " { i } " source= " { h [ " source " ] } " > \n { h [ " text " ] } \n </document> ' for i, h in enumerate ( hits, start = 1 ) ) return f " <documents> \n { blocks } \n </documents> \n\n Question: { question } " def answer ( question: str, category: str | None = None ) -> None: hits = retrieve ( question, category ) if not hits: print ( " Nothing was retrieved from the knowledge base. " ) return response = claude. beta. messages. create ( model = CLAUDE_MODEL, max_tokens = 16000, system = SYSTEM_PROMPT, messages = [{ " role ": " user ", " content ": build_user_message ( question, hits )}], output_config = { " effort ": " medium " }, # if a safety classifier refuses, the server retries on a recommended model (beta) fallbacks = " default ", betas = [ " server-side-fallback-2026-07-01 " ], ) if response. stop_reason == " refusal ": print ( " The model declined to answer. " ) return text = "". join ( b. text for b in response. content if b. type == " text " ) print ( text ) print ( " \n ---- Sources ---- " ) for i, h in enumerate ( hits, start = 1 ): score = f " { h [ ' score ' ]:.
4 f } " if h [ " score " ] is not None else " n/a " print ( f " [ { i } ] { h [ ' source ' ] } (score= { score } ) " ) u = response. usage print ( f " \n (model= { response. model }, input_tokens= { u. input_tokens }, output_tokens= { u. output_tokens } ) " ) # ---------- entry point ---------- def main () -> None: parser = argparse. ArgumentParser ( description = " Pinecone + Claude docs Q&A " ) sub = parser. add_subparsers ( dest = " cmd ", required = True ) p_ingest = sub. add_parser ( " ingest " ) p_ingest. add_argument ( " path ", type = Path ) p_ask = sub. add_parser ( " ask " ) p_ask. add_argument ( " question " ) p_ask. add_argument ( " --category ", default = None ) args = parser. parse_args () if args. cmd == " ingest ": ingest ( args. path ) else: answer ( args. question, args. category ) if __name__ == " __main__ ": try: main () except anthropic. APIStatusError as e: sys. exit ( f " Claude API error { e. status_code }: { e. message } " ) Enter fullscreen mode Exit fullscreen mode Running it Prepare a folder whose top-level subfolders act as categories:
docs/ ├── hr/ │ ├── annual-leave.md │ └── onboarding.md └── finance/ └── expenses.md Enter fullscreen mode Exit fullscreen mode python rag_bot.py ingest./docs python rag_bot.py ask "Do I get annual leave in my first year?" python rag_bot.py ask "What's the hotel limit on business trips?" --category finance USE_RERANK = 0 python rag_bot.py ask "How far ahead do I need to request leave?" # no rerank quota used Enter fullscreen mode Exit fullscreen mode The output shows the answer, the source file behind each [n] with its score, and the token usage for the call so you can check it against your bill.
These are list-price estimates, not a measured bill; your console usage is the source of truth.
Pinecone (as of November 2026, Pricing ):
An internal knowledge base of a few hundred policy docs usually fits within Starter's 2 GB and 5M embedding tokens a month. The first limit you'll hit is usually 500 reranks a month. Either disable rerank on Starter, call it only when recall scores are too close to separate, or upgrade. Read/write unit accounting is detailed in Understanding cost.
Claude (as of November 2026, Pricing ): claude-sonnet-5-5 is $2 per million input tokens, $10 per million output tokens, and $0.10 per million cache-hit tokens.
Rough math for one question with these settings: four chunks plus system prompt and question is a few thousand input tokens; the answer is a few hundred. Input cost is well under a cent; output cost depends on answer length, and thinking tokens are billed as output, which is why effort is worth tuning. For real numbers, multiply the input_tokens / output_tokens the script prints by the unit prices.
If you generate vectors yourself (options B/C), the index dimension must match the model output. Writing with model A and querying with model B gives essentially random results even if the dimensions happen to match. Using an embed field with embed scoring avoids this structurally, because Pinecone uses the model fixed at creation for both. Remember: the schema and embedding config can't change after creation; a new model means a new index and a full re-ingest.
With option B, the quickstart pattern is pc.inference.embed(model=..., inputs=, parameters={"input_type": "passage"}), written into a field declared with add_dense_vector_field, and input_type="query" at search time. Omitting or swapping input_type won't error, but retrieval quality drops.
If the write and query namespaces differ, you get no error, just empty results. The script keeps the namespace in one env var. Namespaces are good for multi-tenant isolation (one per department or customer), but Starter caps an index at 100 namespaces, so many tenants need a higher plan or metadata filtering instead.
Metadata filters are not access control. If finance policies should only be visible to finance staff, the server must add the filter based on the logged-in user; never trust a category sent from the client. For stricter isolation, use separate namespaces or even separate indexes.
Indexing is asynchronous, so there's a short eventual-consistency window. A few seconds of sleep is enough in an ingest script; if your app needs read-after-write, handle it with a retry or a UI hint.
Check these before rolling this out inside a company:
If your docs contain personal data or sensitive business information, get legal and security review first. If data can't leave your environment, this managed stack isn't the right fit; look at self-hostable options like pgvector or Milvus with a model you can run or contract for in-region.
The upside is very little code and no models or databases to operate, which makes it a good way to test whether "Q&A over company docs" is worth building. The downsides are just as clear: Pinecone is a closed managed service and your data lives in its cloud; Starter's rerank and region limits are tight; and costs grow linearly with data and query volume, so reconcile your bill regularly.
Pinecone and Anthropic iterate quickly; check versions before running, and where this article and the official docs disagree, trust the docs.