RAG appears in roughly 65% of applied-LLM job listings for a reason: it is how you make a model answer from your data without fine-tuning. It is also where most LLM features quietly fail — not in the model call, but in retrieval.
Tier: Advanced · Workflow area: Building LLM Features
This playbook builds the pipeline end to end on infrastructure you already run (Postgres + pgvector): deliberate chunking, embeddings, a retrieval endpoint, and grounded answer synthesis. Part 2 (pb-48) upgrades retrieval quality with hybrid search, reranking, and citations.
Skip RAG when the whole corpus fits in the context window comfortably and queries are rare — just include the documents in the prompt with caching.
pgvector extension (CREATE EXTENSION vector;) — no new vendor required.pip install anthropic voyageai psycopg fastapi (or your embedding provider's SDK; OpenAI's text-embedding-3-small is the common alternative).Chunk deliberately — and test two strategies, not zero. Chunking is the highest-leverage decision in the pipeline. Implement both and keep the winner:
Every chunk keeps metadata: doc_id, title, section, updated_at, source URL. You will need all of it for citations and freshness filtering.
Embed and store.
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id text NOT NULL,
title text, section text, url text, updated_at timestamptz,
content text NOT NULL,
embedding vector(1024) -- match your embedding model's dimension
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
Batch-embed chunks (embedding APIs take arrays — one call per 100 chunks, not per chunk). Record the embedding model name in a config table: mixing embeddings from different models in one column produces silent garbage.
Retrieve top-k with a similarity floor.
def retrieve(query: str, k: int = 8) -> list[Chunk]:
qvec = embed([query])[0] # SAME model as ingestion
rows = db.execute(
"""SELECT id, title, section, url, content,
1 - (embedding <=> %s::vector) AS score
FROM chunks ORDER BY embedding <=> %s::vector LIMIT %s""",
(qvec, qvec, k)).fetchall()
return [r for r in rows if r.score > 0.35] # tune floor on your data
The floor matters: with no floor, an off-topic question still "retrieves" the 8 least-irrelevant chunks and the model gamely answers from them.
Synthesize with grounding rules. The prompt does three jobs: scope the model to the context, force refusal on empty retrieval, and demand source attribution:
import anthropic
client = anthropic.Anthropic(timeout=30.0)
def answer(query: str) -> dict:
chunks = retrieve(query)
if not chunks:
return {"answer": None, "reason": "no_relevant_content"}
context = "\n\n".join(
f"[{i}] ({c.title} / {c.section})\n{c.content}" for i, c in enumerate(chunks))
resp = client.messages.create(
model="claude-opus-4-8", max_tokens=1500,
system="Answer ONLY from the numbered context passages. Cite passage "
"numbers like [2] after each claim. If the context does not "
"contain the answer, say exactly: NOT_IN_CONTEXT.",
messages=[{"role": "user",
"content": f"Context:\n{context}\n\nQuestion: {query}"}])
text = resp.content[0].text
return {"answer": None if "NOT_IN_CONTEXT" in text else text,
"sources": [{"title": c.title, "url": c.url} for c in chunks]}
Run the chunking bake-off. For your 10 eval questions, measure per strategy: retrieval hit rate (is the known-correct passage in top-k?) and answer correctness (does the answer match, judged by you). Keep the winning strategy; record the numbers — they are your baseline for pb-48.
Wire ingestion for freshness. Re-chunk and re-embed on document update (hash content per chunk; only re-embed changed chunks). Stale RAG is worse than no RAG — it answers confidently from last quarter's policy.
When a RAG answer is wrong, the cause is almost always retrieval, not generation. Debug by looking at what was retrieved before touching the prompt — that instinct is the skill this playbook builds.
text-embedding-3-small are solid defaults; whatever you pick, pin the model + dimension in config.client.responses.create(model="gpt-5.6", ...) with the same grounding system prompt — the pattern is provider-neutral.NOT_IN_CONTEXT/refusal, not confident fabrication.nomic-embed-text) and generation; quality drops at synthesis, so lean harder on the refusal rule and keep answers extractive.chunks table like any other datastore; embeddings of PII are still personal data.