Your Bedrock agent answers product questions confidently and gets the price wrong. Or it quotes a policy that was retired two releases ago. Or it returns a beautifully formatted response about a SKU that no longer exists. The model is fine. The retrieval is the problem: your agent is pulling from a Bedrock Knowledge Base built off a nightly S3 sync of denormalized JSON, and the moment your real content moved, the vectors went stale.
Sanity Context is Sanity's agent-facing product. Its primary surface today is Context MCP, a hosted, read-only MCP endpoint that exposes schema reads, GROQ queries, reference traversal, and optional semantic search across a Sanity dataset. For unstructured sources (PDFs, support exports, marketing sites) there is a second surface, Knowledge Bases, that turns messy corpora into well-ordered documents. Bedrock keeps owning the model, the agent loop, and the action groups. Sanity Context owns what the agent reads.
This article stays on the Bedrock side first: how Agents and Knowledge Bases actually resolve a query, where the InvokeAgent call goes wrong on structured content, and what to log when it does. Then it shows where Sanity Context slots into that exact pipeline so a date range, a publication state, or a product variant stops getting flattened into an embedding that can't represent it.
How a Bedrock agent actually resolves a query
When you call InvokeAgent, Bedrock runs an orchestration loop. The agent gets your prompt, decides whether it needs to call an action group (a Lambda behind an OpenAPI schema) or query an associated Knowledge Base, runs that step, and feeds the result back into the foundation model for the next turn. The trace you get back (enable it with enableTrace: true) shows each step: the rationale, the invocationInput, and the observation.
The Knowledge Base path is where most teams put their content. You point a Bedrock Knowledge Base at an S3 bucket, pick an embeddings model like amazon.titan-embed-text-v2:0, choose a vector store (OpenSearch Serverless, Aurora pgvector, Pinecone), and Bedrock chunks, embeds, and indexes everything. At query time it runs a RetrieveAndGenerate call: embed the user question, pull the top-k chunks by vector similarity, stuff them into the prompt, generate.
This works well for genuinely unstructured prose. A policy document, an FAQ export, a runbook. The failure starts when the content has structure the embedding throws away. Your catalog entry has a price, an availability state, a list of variants, and a launch date. The chunker turns that into a paragraph of text, the embedder turns the paragraph into 1024 floats, and now 'in stock' and 'discontinued' live a few degrees apart in vector space. The retriever returns the discontinued variant because it reads similar. Nothing in the pipeline knows that availability is a predicate, not a vibe.
Invoking a Bedrock agent with tracing on
Stream a Bedrock agent response and capture the orchestration trace so you can see what the Knowledge Base retrieved.
import {
BedrockAgentRuntimeClient,
InvokeAgentCommand,
} from "@aws-sdk/client-bedrock-agent-runtime";
const client = new BedrockAgentRuntimeClient({ region: "us-east-1" });
const command = new InvokeAgentCommand({
agentId: process.env.BEDROCK_AGENT_ID,
agentAliasId: process.env.BEDROCK_AGENT_ALIAS_ID,
sessionId: "session-123",
enableTrace: true,
inputText: "Which running shoes under $120 are in stock in size 10?",
});
const response = await client.send(command);
// The response is an async event stream.
for await (const event of response.completion ?? []) {
if (event.chunk?.bytes) {
process.stdout.write(new TextDecoder().decode(event.chunk.bytes));
}
if (event.trace?.trace) {
// Inspect orchestrationTrace.observation to see what the
// Knowledge Base actually retrieved before the model spoke.
console.error(JSON.stringify(event.trace.trace, null, 2));
}
}Reading the trace when the answer is wrong
Debugging a wrong agent answer starts in the trace, not the prompt. When a Bedrock agent says something false, the instinct is to rewrite the agent instructions or swap the foundation model. Most of the time that is the wrong layer. The model summarized what it was handed faithfully. What it was handed was wrong.
Pull the orchestrationTrace from the stream and look at the knowledgeBaseLookupOutput inside the observation. That array is the actual chunks the retriever returned, each with a content blob and a score. Read them. If the chunk that grounded the answer describes a discontinued product, or a price from an old import, or the wrong region's policy, you have a retrieval failure, and no amount of prompt engineering fixes a retrieval failure. The model can only reason over what it sees.
This is also where the S3-sync architecture bites you in production. Your Bedrock Knowledge Base is a snapshot. It is as fresh as your last ingestion job, your last chunking run, and your last vector reindex. If marketing changed a price at 2pm and your sync runs at 2am, the agent is wrong for twelve hours and the trace looks perfectly healthy: high similarity score, confident generation, stale source. The fix is not a better embedding model. It is reading from the system of record at query time instead of from a copy of it.
Log the retrieval alongside the response on every call. Store the user question, the retrieved chunk IDs and scores, and the final text. When a support ticket comes in about a bad answer, you want to replay what the agent saw, not guess.
A confident trace can still be wrong
Why structured queries break pure vector retrieval
Look at the question from the code sample: 'Which running shoes under $120 are in stock in size 10?' That sentence has three predicates and one semantic part. Under $120 is a numeric filter. In stock is a state filter. Size 10 is a variant filter. Running shoes is the only piece that benefits from semantic matching, and even that is arguable against a well-tagged catalog.
A pure RetrieveAndGenerate call embeds the whole sentence and asks the vector index for nearest neighbors. The index has no way to enforce 'price < 120'. It can only find chunks that are textually near the question, and a $200 shoe described as 'great for budget-conscious runners' will happily rank high. You can attach a metadata filter to a Bedrock Knowledge Base query, which helps, but you are now maintaining a parallel metadata schema in your vector store that has to stay in sync with your real data, and it only filters on fields you remembered to denormalize at ingest time.
The deeper issue is that vector search and good retrieval are not the same thing. Vector search is one ingredient. For structured content, the right primitive is a query language that can express predicates and ranking in the same statement, against live data. For genuinely unstructured content, semantic search earns its place. The mistake is using one tool for both. Production data from agents running on structured content backs this up: the heavy majority of useful retrieval calls are structured lookups and predicate filters, and semantic search is a smaller slice you reach for when keyword and structure alone miss.
Wiring Sanity Context into the agent as a tool source
Bedrock agents call tools through action groups: an OpenAPI schema describing each operation, backed by a Lambda. To give a Bedrock agent governed, live, structured content, you expose Sanity Context as one of those tools and let the model decide when to call it.
The fastest way in is Context MCP, the hosted, read-only MCP endpoint. If your agent loop runs in a runtime that speaks MCP, you attach the endpoint and the agent gets schema-aware tools out of the box: it can read your content model, traverse references, and run GROQ queries. Because the endpoint is read-only, the agent cannot mutate your dataset through it; writes go through Agent Actions, not MCP. That read-only constraint is a feature when an autonomous loop is on the other end.
For a classic Bedrock action group, wrap a typed GROQ query in a Lambda and describe it in the OpenAPI schema. The Lambda runs the query against the Content Lake using the standard client and returns structured JSON the agent can ground on. Now 'in stock' is a predicate the query enforces, 'under $120' is a numeric comparison the database evaluates, and the result is live, because you queried the system of record at invocation time rather than a nightly snapshot of it.
A Bedrock action group Lambda backed by a GROQ query
A Lambda backing a Bedrock action group runs a GROQ query against live content so price, stock, and variant are enforced as predicates, not vectors.
import { createClient } from "next-sanity";
const sanity = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: "production",
apiVersion: "2024-01-01",
useCdn: false, // read fresh, not a cached snapshot
});
// Bedrock invokes this Lambda for the 'searchCatalog' action.
export const handler = async (event: any) => {
const { maxPrice, size } = event.parameters ?? {};
const result = await sanity.fetch(
`*[_type == "product"
&& inStock == true
&& price <= $maxPrice
&& $size in variants[].size]{
title, price, slug, "variants": variants[size == $size]
}`,
{ maxPrice: Number(maxPrice), size }
);
return {
response: {
actionGroup: event.actionGroup,
apiPath: event.apiPath,
httpMethod: event.httpMethod,
httpStatusCode: 200,
responseBody: { "application/json": { body: JSON.stringify(result) } },
},
};
};When you do want semantic search: hybrid inside one GROQ query
Some questions really are semantic. 'Something comfortable for long runs on pavement' has no clean predicate. For those, Sanity Context can run semantic similarity, but the discipline is hybrid retrieval, not embeddings everywhere. Embeddings are opt-in and off by default on Context MCP, and most projects shipping on it never turn them on, because structured retrieval already answers the heavy majority of calls. You reach for semantic search when the agent's failures justify it, and you keep the structural predicates in the same query so they still constrain the result.
GROQ expresses this in one statement. You filter on the structured predicates inside the brackets, then score the candidates with semantic similarity and an optional keyword boost, then order by the combined score. The structural filters are predicates, not part of the scoring, so 'in stock' and 'under $120' are still enforced as hard constraints while the ranking handles the fuzzy part. One query, one round trip, no parallel metadata store to keep in sync.
The routing rule is simple. Structured content like a catalog or schema-backed articles goes through GROQ retrieval. Unstructured content like PDFs, marketing sites, and support exports goes through Sanity Context Knowledge Bases, which turns those messy sources into ordered documents with a table of contents the agent can navigate. High-volume machine-generated corpora that need no editorial governance can still live in a dedicated vector store; not everything belongs in Sanity. Match the tool to the shape of the content.
Hybrid retrieval in one GROQ query
Structural predicates stay inside the filter; semantic similarity and a keyword boost combine into _score for ranking.
*[_type == "product" && inStock == true && price <= 120]
| score(
boost(title match text::query($queryText), 1.5),
text::semanticSimilarity($queryText)
)
| order(_score desc)[0...5]{
title, price, slug, _score
}Tracing the retrieval layer in production
Once Sanity Context is the tool source, your debugging story changes shape. The Bedrock trace already gives you the orchestration steps. Now extend it: for every action group call, log the GROQ query the Lambda ran and the rows it returned, and for every MCP tool call, log the tool name and the response. When an answer is wrong, you replay the exact query and result instead of guessing whether the model or the data failed.
This matters because production agent debugging almost always traces back to a bad retrieval, not a bad model. With a vector-only Knowledge Base, the trace tells you which chunk scored highest but not why a more correct chunk lost. With a GROQ query in the trace, the failure is legible: you can see that the predicate was too narrow, or that the content the agent needed was in draft and your query filtered drafts out, or that a reference the agent traversed pointed at an unpublished document. You read the query, you read the result, you fix the query.
Sanity is the AI Content Operating System, an intelligent backend for companies building AI content operations at scale, and that framing pays off here. Because the content lives in a governed system rather than a flattened S3 snapshot, the things an editor changes (a price, an availability flag, a published state, an approved answer) are versioned and reviewable through primitives like Content Releases, and the agent reads the current truth at query time. The model stays on Bedrock. The retrieval stays legible. The content stays governed, edited by humans, and previewable before it goes live.