Knowledge Bases (managed RAG)
A Bedrock Knowledge Base is Ch 3's RAG pipeline, managed: embeddings, vector store, chunking and retrieval behind two calls. You bring S3 docs; AWS keeps the index in sync.
- AWS credentials (
aws configure) + Bedrock model access enabled in your region +pip install boto3 - AWS credentials (
aws configure) +pip install boto3
Learning objectives
- Explain what a Bedrock Knowledge Base gives you over hand-rolled RAG (Ch 3).
- Create a KB backed by OpenSearch Serverless and ingest documents from S3.
- Query with
Retrieve(chunks only) andRetrieveAndGenerate(answer). - Decide when managed RAG beats owning the pipeline yourself.
30-second recap
- Bedrock & Claude: Amazon Bedrock hosts Claude and other models behind AWS IAM and billing; you call them with the AWS SDK instead of a vendor API key.
- RAG foundations: Retrieval-augmented generation: find the relevant passages first, then give them to the model as context, so answers come from your documents instead of memory.
code/aws4-bedrock-kb-rag/ in the course, with a README on how to run it. Read here, run there — no need to copy-paste.Managed RAG vs. the hand-built pipeline essential
Here's the moment this chapter exists for. You shipped the Ch 3 RAG pipeline and it worked beautifully — for a week. Then someone updated a support doc and nobody re-ran the embedding script, so the bot kept quoting the old refund policy. Then the vector store filled its disk. Then a new hire asked "where do I change the chunk size?" and the answer was buried in three files. None of that was a retrieval problem; it was an operations problem. The retrieval logic you wrote was fine — keeping it alive in production was the tax you didn't budget for. A managed Knowledge Base is AWS offering to pay that tax for you.
The clean way to think about it: RAG has two halves. There's the algorithm — chunk the text, embed each chunk, store the vectors, find the nearest ones to a query, stuff them into a prompt — and there's the plumbing that keeps that algorithm running: a vector database that's provisioned, patched, backed up, and scaled; a job that re-embeds documents when they change; an index that stays consistent. Ch 3 taught you the algorithm by making you build both halves by hand. A Bedrock Knowledge Base keeps the exact same algorithm but hands the plumbing to AWS. You point it at documents in S3, name an embeddings model and a vector store, and from then on you interact through essentially two calls — one to retrieve, one to retrieve-and-answer.
Think of it like the difference between running your own Postgres server and using a managed database. The queries you write are identical; what changes is that you stop being paged at 3 a.m. about disk space and replication. That analogy is exactly why this is worth its own chapter and not a footnote: the decision to go managed is a decision about who owns the operational burden, not about whether RAG works differently. The retrieval quality is still governed by the same knobs you learned in Ch 3 — you'll just set them as configuration instead of writing them as code.
The common mistake here is to read "managed" as "magic" and assume the Knowledge Base will also fix bad retrieval. It won't. If your documents are poorly structured, your chunks too big, or your query vague, a managed KB gives you the same mediocre answers your hand-built pipeline would — just with less code to blame. Managed RAG removes the plumbing toil; it does not remove the need to understand what makes retrieval good. Keep Ch 3's mental model in your head as you read: everything below is that model, wearing an AWS uniform.
In Ch 3 you built RAG from scratch: chunk, embed, store, retrieve, stuff the prompt. A Bedrock Knowledge Base manages all of that — embeddings, a vector store, chunking, and retrieval — behind two API calls. You bring documents in S3; AWS keeps the index in sync.
Compare the two columns side by side: hand-built RAG on the left, a managed Knowledge Base on the right. Each column is split into a top row (the algorithm) and a bottom row (the plumbing).
- The top row is the same in both columns — chunk, embed, store, retrieve. That's the point: managed RAG does not change what RAG does, only how you express it (code on the left, config on the right).
- The bottom row is where the columns diverge. On the left it's a red box you own: provisioning, patching, backups, scaling, and re-embedding when documents change. On the right that same row turns green — AWS owns it.
- The arrow between the columns is the move you make by going managed: you hand the red plumbing box to AWS. The green box is the "aha" — the operational tax you stop paying.
In short: the retrieval algorithm is identical either way; choosing a managed KB is a decision about who owns the plumbing, not about whether RAG works differently.
Create the KB and ingest essential
The first time people stand up a Knowledge Base, they hit the same wall — and it's instructive. They call create_knowledge_base, get back an ID, feel triumphant, immediately fire a query… and get nothing back. The KB is empty. What went wrong is the single most important idea in this section: creating a Knowledge Base and filling it with your documents are two separate steps. Creation builds the container; a later ingestion job is what actually reads your files, embeds them, and loads the index. Miss that distinction and you'll spend an afternoon debugging an index that was never populated.
It helps to picture a KB as a library that you're commissioning. Three things have to be decided before the building can hold a single book: where the books come from (an S3 data source — your bucket of documents), who translates them into a searchable form (an embeddings model, which turns each chunk of text into a vector of numbers capturing its meaning), and the shelving system that stores and finds those vectors (a vector store — OpenSearch Serverless is Bedrock's default). The create_knowledge_base call is you signing the commission with all three specified. But signing the paperwork doesn't move any books in — that's the ingestion job, which comes next.
One more thing that trips newcomers and explains a design choice later in the track: these pieces have an ordering dependency. The vector store and the IAM role the KB uses to reach your S3 bucket must already exist before you create the KB, because the KB immediately tries to wire itself to them. Get the order wrong and the call fails. That fragile hand-assembly — create the collection, then the role, then the KB, then trigger ingestion, each depending on the last — is precisely why production teams stop clicking through boto3 and reach for infrastructure-as-code (W7), where the tool works out the dependency order for you. Reading this section by hand teaches you what those Terraform resources are actually doing under the hood.
The other detail worth internalizing now: ingestion is asynchronous and repeatable. The job returns immediately with a status like STARTING and churns through your documents in the background — you don't sit and wait for it. And because it's a job you trigger rather than a magic live sync, you must re-run it every time the underlying S3 documents change. A Knowledge Base is only as fresh as its last ingestion; nothing about "managed" means "automatically watching your bucket."
A KB needs: an S3 data source, an embeddings model, and a vector store (OpenSearch Serverless is the default). We create it here with boto3; W7 shows the Terraform/CDK version you would actually use in production.
create_knowledge_base, which returns an empty KB id; only the later start_ingestion_job reads S3, embeds, and indexes — and it runs asynchronously through STARTING → IN_PROGRESS → COMPLETE.
Follow the solid blue arrows left to right along the top, then drop down the dashed arrow to the ingestion job and its status track along the bottom.
- The three boxes on the far left are the KB's ingredients: an S3 data source, an embeddings model, and the vector store + IAM role. The amber box is flagged "must exist first" — the store and role are the ordering dependency the section warns about.
- They all feed
create_knowledge_baseon the bedrock-agent control plane, which returns a green KB id — but it's empty. Signing the paperwork moved no documents in. - The dashed arrow down is the separate
start_ingestion_jobcall. It runs in the background, which is why the bottom track walks STARTING → IN_PROGRESS → COMPLETE with dashed "still working" arrows rather than finishing instantly.
In short: creating a KB gives you an empty container; a separate, asynchronous ingestion job is what actually indexes your docs — and you re-run it every time they change.
create_kb.pyimport boto3
agent = boto3.client("bedrock-agent", region_name="us-east-1") # KB control plane
kb = agent.create_knowledge_base(
name="support-docs",
roleArn="arn:aws:iam::123456789012:role/bedrock-kb-role", # needs S3 + OpenSearch access
knowledgeBaseConfiguration={
"type": "VECTOR",
"vectorKnowledgeBaseConfiguration": {
"embeddingModelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0"
},
},
storageConfiguration={
"type": "OPENSEARCH_SERVERLESS",
"opensearchServerlessConfiguration": {
"collectionArn": "arn:aws:aoss:us-east-1:123456789012:collection/abc123",
"vectorIndexName": "support-index",
"fieldMapping": {"vectorField": "vec", "textField": "text", "metadataField": "meta"},
},
},
)
print("KB id:", kb["knowledgeBase"]["knowledgeBaseId"])
This creates a Knowledge Base (KB) — Amazon's managed version of the RAG pipeline you hand-built in Ch 3. Instead of writing code to chunk, embed and store documents, you describe what you want and AWS runs the machinery. This one block declares all three pieces a KB needs: where documents live, which model turns text into vectors, and where those vectors are stored.
boto3.client("bedrock-agent", ...)opens a connection to the AWS service that manages Knowledge Bases (the "control plane" — the part that creates and configures things).region_name="us-east-1"picks the AWS data-centre region.agent.create_knowledge_base(...)is the single call that builds the KB. Everything inside the parentheses is configuration passed as named arguments.nameis a human label;roleArnis an AWS IAM role — an identity with permission to read your S3 documents and write to the vector store. The KB acts as this role when it works.knowledgeBaseConfigurationsays the KB is aVECTORstore and names the embedding model (amazon.titan-embed-text-v2) — the model that converts each chunk of text into a list of numbers (a vector) capturing its meaning.storageConfigurationpoints at OpenSearch Serverless, the default vector database.fieldMappingtells it which fields hold the vector, the original text, and metadata.collectionArnis the unique address of that store.print("KB id:", ...)pulls the new KB's ID out of the reply. You'll reuse this ID in every later call, so note it down.
What the output means: On success AWS prints something like KB id: KB123. That ID is the handle you pass to the ingestion and query calls below. The ARNs and IDs shown are placeholders — swap in your own.
Try this: Read the three sub-configs top to bottom and match them to the KB's three needs: S3 docs (added next), embedding model (embeddingModelArn), and vector store (opensearchServerlessConfiguration). Seeing all three named here is the whole point.
After attaching an S3 data source you trigger an ingestion job to embed and index. Re-run it whenever the documents change.
ingest.pyimport boto3
agent = boto3.client("bedrock-agent", region_name="us-east-1")
job = agent.start_ingestion_job(
knowledgeBaseId="KB123",
dataSourceId="DS456",
)
print("ingestion job:", job["ingestionJob"]["status"]) # STARTING -> IN_PROGRESS -> COMPLETE
Creating the KB in W4.1 built the empty container. Your documents are not indexed yet. An ingestion job is the step that actually reads the files from S3, chunks them, calls the embedding model on each chunk, and writes the resulting vectors into OpenSearch. You run this once after setup, and again every time the documents change.
- We reconnect to
bedrock-agent— the same control-plane client as W4.1, because starting an ingestion job is a management action. agent.start_ingestion_job(...)kicks off the indexing work.knowledgeBaseIdis the KB from W4.1;dataSourceIdidentifies the S3 data source you attached to it (KB123/DS456are placeholders).- The job runs asynchronously — the call returns immediately with a starting status rather than waiting for all documents to finish.
print(...job["ingestionJob"]["status"])shows that first status.
What the output means: You'll see a status like STARTING. Behind the scenes it moves STARTING → IN_PROGRESS → COMPLETE (the comment spells this out). You would poll the job again later, or check the console, to confirm it reached COMPLETE before querying.
Try this: Think about why re-running matters: if you add a new support doc to S3 tomorrow, the KB won't know about it until you start another ingestion job. Managed RAG syncs on demand, not by magic.
Query: Retrieve vs RetrieveAndGenerate intermediate
Now the index is full, and you face a fork that quietly decides how much of the RAG pipeline you still own. Picture two engineers on the same team. One is building a quick internal help-desk bot and just wants a grounded answer with sources — done by lunch. The other is building a regulated compliance assistant that needs a hand-crafted system prompt, a custom reranking step, and a specific model with tuned parameters. Both are querying the same Knowledge Base, but they should not use the same call. Bedrock gives you two doors, and picking the wrong one means either fighting a black box or rebuilding machinery you didn't need to.
The mental model is "how much of the last mile do you want to drive yourself." retrieve is the short door: it does the vector search and hands you back the matching chunks — raw text and their source locations — and then stops. Generation is now entirely your job. You assemble those chunks into whatever prompt you like, choose whatever model you like, and call it yourself. This is the choice when you need control: your own system prompt, your own reranking, your own template. retrieve_and_generate is the long door: one call retrieves the chunks, feeds them to the model you name, and returns a finished natural-language answer plus citations that point back to the source documents. It's the whole RAG loop collapsed into a single request.
Here's the analogy that makes the trade stick: retrieve is buying the ingredients, retrieve_and_generate is ordering the finished dish. If you just want to eat, order the dish — it's faster and it arrives plated with a receipt (the citations) showing exactly which documents it was made from. If you're a chef with a specific recipe in mind, you want the raw ingredients so you control the seasoning. Neither is "better"; they sit at opposite ends of the convenience-versus-control spectrum, and a good engineer reaches for whichever the task actually demands.
The subtle point most people miss: the citations that retrieve_and_generate returns are not decoration — they're the feature that makes managed RAG trustworthy. An answer you can't trace back to a source is, for anything auditable, barely better than a guess. Because the citations name the exact chunks used, you can enforce a hard rule in your own code — "if there are no citations, refuse to answer" — which is far stronger than politely asking the model to cite. Retrieval grounds the answer; the citations prove the grounding. That's the quiet reason teams pick a managed KB for compliance work even when they could have hand-built the retrieval themselves.
retrieve returns the matching chunks — use it when you want to control the prompt (feed chunks to Claude yourself). retrieve_and_generate does retrieval + answering in one call, with citations.
retrieve (amber) hands you the raw chunks and stops — you own the prompt, model, and reranking; retrieve_and_generate (green) carries on to a finished answer with citations in a single call. Convenience versus control, nothing more.
Start at the top box and follow the two arrows down — both branches begin from the same vector search over the Knowledge Base, then split into the two call styles.
- The left (amber) branch is
retrieve, the "short door": it returns the raw matching chunks and stops. The box below shows what's now on you — building the prompt, choosing the model, doing your own reranking. - The right (green) branch is
retrieve_and_generate, the "long door": one call retrieves, feeds the chunks to the model you name, and returns a finished answer. Its box below is the payoff — an answer plus citations that point back to the source documents. - The split itself is the "aha": both start from identical retrieval, so the choice is purely convenience versus control — reach for whichever the task demands.
In short: retrieve is buying ingredients (you cook); retrieve_and_generate is ordering the plated dish with a receipt (the citations).
query_kb.pyimport boto3
rt = boto3.client("bedrock-agent-runtime", region_name="us-east-1") # KB data plane
# One-call RAG: retrieves, then answers with Claude, with citations.
resp = rt.retrieve_and_generate(
input={"text": "How do I reset a customer's password?"},
retrieveAndGenerateConfiguration={
"type": "KNOWLEDGE_BASE",
"knowledgeBaseConfiguration": {
"knowledgeBaseId": "KB123",
"modelArn": "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0",
},
},
)
print(resp["output"]["text"])
for c in resp["citations"]:
for ref in c["retrievedReferences"]:
print(" source:", ref["location"])
Now we ask a question against the indexed KB. Bedrock offers two query styles: retrieve returns just the matching chunks (you then write your own prompt to Claude), and retrieve_and_generate — used here — does the whole RAG loop in one call: find relevant chunks, feed them to Claude, and return a written answer plus citations pointing back to the sources.
boto3.client("bedrock-agent-runtime", ...)connects to the data plane — the part that uses the KB to answer queries (as opposed to the control plane that built it). Different job, different client.rt.retrieve_and_generate(...)is the one-call RAG.input={"text": "How do I reset a customer's password?"}is the user's question in plain English.retrieveAndGenerateConfigurationtells it how to answer:type: KNOWLEDGE_BASEmeans "use a KB",knowledgeBaseIdpicks which KB, andmodelArnchooses the model that writes the answer — here Claude 3.5 Sonnet.print(resp["output"]["text"])prints Claude's final written answer, grounded in your documents.- The
for c in resp["citations"]loop walks the citations. Each citation listsretrievedReferences— the actual document chunks used — and we print eachref["location"], i.e. where that fact came from. This is how you prove the answer isn't made up.
What the output means: You get a natural-language answer to the password question, followed by one or more source: lines showing which S3 documents backed it. Citations are the key advantage of managed RAG — grounding and traceability come free.
Try this: Decide which call you'd want for two tasks: (1) a chatbot that shows a polished answer — retrieve_and_generate; (2) a custom prompt where you control every instruction to Claude — plain retrieve, then build the prompt yourself.
Exercise W4.1 — Compare to Ch 3
Context: The honest way to judge a managed Knowledge Base is to race it against the hand-built RAG pipeline you already wrote, over the same documents. The interesting answer is rarely 'which is more accurate' — it's 'which cost less to maintain'.
Your task: Point a Bedrock KB at the same documents from your Ch 3 RAG lab, ask five questions through both retrieve_and_generate and your hand-built pipeline, and compare where they differ and which was less work to maintain.
Requirements:
- Ingest the identical Ch 3 corpus into a Knowledge Base
- Run the same five questions through both the managed KB and the hand-built pipeline
- Compare answers and citations side by side, noting where they diverge
- Judge each on operational cost — which required less code and upkeep
- Draw a concrete conclusion about when the managed KB's lost control is worth the saved ops
💡 Hint: Hold the corpus and the questions fixed so the only variables are the retrieval stack and the maintenance effort — that's what makes the comparison fair.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Bedrock Knowledge Bases collapse the entire RAG pipeline — retrieve, generate, and cite — into a single managed call. It's the fastest path from 'documents in S3' to a grounded answer without building any retrieval yourself.
Your task: Ask a question against a Knowledge Base with retrieve_and_generate and print the grounded answer.
Requirements:
- Use the
bedrock-agent-runtimeclient (the KB data plane) - Pass the question via
input={"text": ...} - Configure
type: "KNOWLEDGE_BASE"with aknowledgeBaseIdand a generation model ARN - Print the grounded answer from
output.text
💡 Hint: Note the third client family: management is bedrock-agent, plain inference is bedrock-runtime, but KB queries go through bedrock-agent-runtime.
Show solution
bedrock-agent-runtime is the data plane. retrieve_and_generate retrieves, answers, and cites in one call.
import boto3
rt = boto3.client("bedrock-agent-runtime", region_name="us-east-1")
resp = rt.retrieve_and_generate(
input={"text": "What is our refund window?"},
retrieveAndGenerateConfiguration={
"type": "KNOWLEDGE_BASE",
"knowledgeBaseConfiguration": {
"knowledgeBaseId": "KB123456",
"modelArn": "arn:aws:bedrock:us-east-1::foundation-model/"
"anthropic.claude-3-5-sonnet-20241022-v2:0",
},
},
)
print(resp["output"]["text"])
Context: A grounded answer you can't trace isn't much better than a guess. Knowledge Bases return citations that map each answer back to the exact source chunks in S3 — surfacing them is what makes the answer auditable.
Your task: Extend the retrieve_and_generate call to also print the source document location of each citation.
Requirements:
- Keep printing the generated answer from
output.text - Iterate the
citationslist on the response - For each citation, walk its
retrievedReferences - Print each reference's
location(the source S3 doc)
💡 Hint: Citations are nested two levels deep — a list of citations, each holding a list of retrievedReferences, each with its own location.
Show solution
Citations trace the answer back to source S3 docs via retrievedReferences[].location.
import boto3
rt = boto3.client("bedrock-agent-runtime", region_name="us-east-1")
resp = rt.retrieve_and_generate(
input={"text": "What is our refund window?"},
retrieveAndGenerateConfiguration={
"type": "KNOWLEDGE_BASE",
"knowledgeBaseConfiguration": {
"knowledgeBaseId": "KB123456",
"modelArn": "arn:aws:bedrock:us-east-1::foundation-model/"
"anthropic.claude-3-5-sonnet-20241022-v2:0",
}})
print(resp["output"]["text"])
for c in resp.get("citations", []):
for ref in c.get("retrievedReferences", []):
print("source:", ref["location"])
Context: Sometimes the managed generation is too rigid — you want your own system prompt, reranking, or model. Retrieve hands you just the raw chunks so you own everything downstream, trading convenience for control.
Your task: Use retrieve (not retrieve_and_generate) to fetch raw chunks, build your own prompt from them, and explain when you'd choose this split.
Requirements:
- Call
retrievewith aknowledgeBaseIdand avectorSearchConfiguration(e.g.numberOfResults) - Pull the chunk text out of
retrievalResults[].content.text - Assemble the chunks into your own context/prompt to send to
conversewith your chosen model - Justify the choice: pick retrieve-only when you need control over prompt, reranking, or model that the managed call hides
💡 Hint: retrieve stops at the chunks — generation is now your job, which is exactly the point when the one-call version is too much of a black box.
Show solution
retrieve returns chunks only, so you control the prompt, reranking, and model. Choose it when the managed generation is too rigid.
import boto3
rt = boto3.client("bedrock-agent-runtime", region_name="us-east-1")
r = rt.retrieve(
knowledgeBaseId="KB123456",
retrievalConfiguration={"vectorSearchConfiguration": {"numberOfResults": 5}},
retrievalQuery={"text": "refund window"},
)
chunks = [x["content"]["text"] for x in r["retrievalResults"]]
prompt = "Context:\n" + "\n---\n".join(chunks) + "\n\nQuestion: ..."
# then send `prompt` to converse() with your own system prompt/model
print(len(chunks), "chunks retrieved")
Context: Standing up a Knowledge Base wires three things in order — an S3 data source, an embedding model, and a vector store — and then ingestion runs asynchronously. Knowing it's async is what stops you from querying an empty index.
Your task: Sketch create_knowledge_base with a Titan embedding model plus an OpenSearch Serverless store, kick off an ingestion job, and note its async status lifecycle.
Requirements:
- Use the
bedrock-agentcontrol-plane client and supply aroleArn - Configure a
VECTORKB with an embedding model ARN (e.g.amazon.titan-embed-text-v2:0) - Point
storageConfigurationat an OpenSearch Serverless collection with a vector index and field mapping - Start ingestion with
start_ingestion_joband read itsstatus - Recognize the async lifecycle:
STARTING→IN_PROGRESS→COMPLETE
💡 Hint: Creation returns immediately with a KB id, but the documents aren't queryable until the separate ingestion job finishes — poll its status, don't assume.
Show solution
A KB needs three things wired in order: an S3 data source, an embedding model ARN, and a vector store. Ingestion is async.
import boto3
agent = boto3.client("bedrock-agent", region_name="us-east-1")
kb = agent.create_knowledge_base(
name="support-kb",
roleArn="arn:aws:iam::123:role/kb-role",
knowledgeBaseConfiguration={"type": "VECTOR",
"vectorKnowledgeBaseConfiguration": {"embeddingModelArn":
"arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0"}},
storageConfiguration={"type": "OPENSEARCH_SERVERLESS",
"opensearchServerlessConfiguration": {
"collectionArn": "arn:aws:aoss:...",
"vectorIndexName": "kb-index",
"fieldMapping": {"vectorField":"v","textField":"t","metadataField":"m"}}},
)
kb_id = kb["knowledgeBase"]["knowledgeBaseId"]
job = agent.start_ingestion_job(knowledgeBaseId=kb_id, dataSourceId="DS123")
print(job["ingestionJob"]["status"]) # STARTING -> IN_PROGRESS -> COMPLETE
Context: Managed KB versus hand-built RAG is a control-vs-ops trade, not a quality one. A KB owns chunk size, retrieval count, and reranking for you; you give up those knobs in exchange for far less operational work. Encoding the rule makes the decision defensible.
Your task: Encode rag_choice(need_chunk_control, need_custom_rerank, want_low_ops) returning 'managed-kb' or 'hand-built', with a justification.
Requirements:
- Needing chunk control or custom reranking forces
'hand-built'— those are knobs the KB hides - Wanting low ops (and no special control) favours
'managed-kb' - The default leans
'managed-kb'when nothing demands the extra control - Run offline; verify the branches with example inputs
- Capture the trade-off in a comment: managed KB owns chunk/embed/store, you own less
💡 Hint: Check the control-requiring conditions first — if any knob the KB hides is required, hand-built wins before low-ops preference even enters the picture.
Show solution
A managed KB owns chunk size, retrieval count, and reranking; you trade that control for far less operational work.
def rag_choice(need_chunk_control, need_custom_rerank, want_low_ops):
if need_chunk_control or need_custom_rerank:
return "hand-built" # you need the knobs the KB hides
if want_low_ops:
return "managed-kb" # AWS owns chunk/embed/store
return "managed-kb"
print(rag_choice(False, False, True)) # managed-kb
print(rag_choice(True, False, True)) # hand-built
Context: For a compliance use case, an answer without a source is worse than no answer. Grounding is only enforceable if you actually inspect the citations and refuse when none came back — the model's confidence is not evidence.
Your task: Wrap retrieve_and_generate so any response with no citations is rejected with a safe refusal message instead of being returned.
Requirements:
- Pull all
retrievedReferencesout of the response'scitations - If there are zero references, return a safe refusal and mark it
refused: True— do not surface the model's text - If references exist, return the answer plus the list of source
locations andrefused: False - The guard logic is pure and testable offline against fake grounded and ungrounded responses
- Enforce the rule in code — asking the prompt to 'please cite' is not a guarantee
💡 Hint: The presence of a citation, not the wording of the answer, is your signal — flatten citations to their references and treat an empty list as an automatic refusal.
Show solution
Grounding is only enforceable if you check the citations; no source means no answer.
def enforce_citations(resp):
cites = resp.get("citations", [])
refs = [r for c in cites for r in c.get("retrievedReferences", [])]
if not refs:
return {"answer": "I can't answer without a cited source.",
"sources": [], "refused": True}
return {"answer": resp["output"]["text"],
"sources": [r["location"] for r in refs], "refused": False}
# offline test with a fake grounded response and an ungrounded one:
grounded = {"output": {"text": "30 days."},
"citations": [{"retrievedReferences": [{"location": "s3://kb/faq.md"}]}]}
print(enforce_citations(grounded)["refused"]) # False
print(enforce_citations({"output":{"text":"guess"}})["refused"]) # True
✓ Checkpoint — you can move on when you can…
- Explain what a Knowledge Base manages that Ch 3 made you build.
- List the three things a vector KB needs (data source, embeddings model, vector store).
- Choose between
retrieveandretrieve_and_generatefor a given task. - Explain why the provisioning order pushed us toward IaC (W7).
Knowledge check check yourself
A Bedrock Knowledge Base needs three things to exist as a vector store. What are they, and which client (control plane vs data plane) creates versus queries it?
Show answer
When would you use Retrieve versus RetrieveAndGenerate against a KB?