Your Instructor pipeline validates beautifully in tests. The Pydantic model is tight, `max_retries` is set, and the LLM reliably returns a well-formed object. Then it hits production and the validation still passes, but the data is wrong. The `product_sku` the model returned looks like a SKU. It just isn't one that exists in your catalog. Instructor guaranteed the shape, not the truth, and a schema-valid hallucination is the hardest kind to catch.
This is where Sanity Context fits. 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. A second surface, Knowledge Bases, handles unstructured sources like PDFs, support docs, and websites. It does not replace Instructor. It sits underneath it, supplying the real, current, structured facts the model fills its fields with.
This article stays in Instructor first: how to push validation past shape-checking into real-world grounding, how to use `field_validator` to reject values that don't exist, and how to feed the model retrieved context so it stops inventing fields it could have looked up. Sanity Context shows up in the last third, as the thing your validators query against.
Instructor guarantees shape, not correctness
Instructor's core move is patching your LLM client so every completion is coerced into a Pydantic model. You define the model, pass `response_model`, and Instructor handles the function-calling plumbing, parses the response, and runs Pydantic validation. If validation fails, it feeds the error back to the model and retries up to `max_retries`. It is genuinely the cleanest way to get typed objects out of an LLM in Python.
The failure mode shows up once you trust it too much. Pydantic validates types and constraints you can express statically: this field is a string, that one matches a regex, this number is positive. It cannot validate that a value corresponds to something real. A model asked to extract an order will happily return `OrderItem(sku='WID-4402', quantity=3)`. The SKU is the right length, matches your pattern, parses cleanly. It just never existed. Validation passes, the object is well-typed, and the bug ships.
This is worth saying plainly because it is easy to conflate the two guarantees. Structured output solves parsing. It does not solve grounding. The model is still generating tokens from its weights, and when it does not know a value, a function-calling interface does not stop it from inventing one. It arguably makes things worse, because a clean typed object reads as more trustworthy than a messy JSON blob a human would scan and question.
A model that validates but lies
Pydantic confirms the SKU matches the pattern. It cannot confirm the SKU is real.
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field
client = instructor.from_openai(OpenAI())
class OrderItem(BaseModel):
sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
quantity: int = Field(gt=0)
item = client.chat.completions.create(
model="gpt-4o",
response_model=OrderItem,
max_retries=2,
messages=[{"role": "user", "content": "Customer wants 3 of the blue widget"}],
)
# OrderItem(sku='WID-4402', quantity=3)
# Valid shape. The SKU does not exist in your catalog.Push validation past shape with field_validator
Instructor's most underused feature is that Pydantic validators run inside the retry loop. When a `field_validator` raises a `ValueError`, Instructor catches it, formats the message, and sends it back to the model as a correction. So you are not limited to static constraints. You can run arbitrary Python during validation, including a lookup against a source of truth, and the model gets a real correction signal when it gets a value wrong.
The pattern looks like this: instead of only checking that the SKU matches a regex, check that it exists. If it doesn't, raise with a message specific enough that the model can recover, ideally one that hands it the valid options. This turns the retry loop from cosmetic reformatting into actual error correction. The model says `WID-4402`, your validator says `that SKU is not in the catalog, valid options for blue widgets are WID-0012, WID-0099`, and the retry produces a real value.
The catch is what backs the validator. A hardcoded set works in a demo and rots immediately in production, because catalogs, author lists, product variants, and publication states all change without your code changing. You need the validator to query live data. That query has to be fast, because it runs inside a per-request retry loop, and it has to return structured results you can match against. This is exactly the seam where a structured-content source belongs.
A validator that rejects values that don't exist
The ValueError message becomes the model's correction prompt on retry.
from pydantic import BaseModel, Field, field_validator
VALID_SKUS = fetch_valid_skus() # must be live, not hardcoded
class OrderItem(BaseModel):
sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
quantity: int = Field(gt=0)
@field_validator("sku")
@classmethod
def sku_must_exist(cls, v: str) -> str:
if v not in VALID_SKUS:
raise ValueError(
f"SKU {v} is not in the catalog. "
f"Valid options: {sorted(VALID_SKUS)[:10]}"
)
return v
# Instructor feeds the ValueError back to the model and retries.Ground the model before it guesses
Correction loops are reactive. They fix wrong values after the model produces them, which costs you tokens, latency, and retries that can still exhaust `max_retries` and fail the request. The better lever is to give the model the right facts up front, so it fills fields by selecting from real data rather than generating from priors.
In Instructor terms this is just context injection: you retrieve the relevant records and put them in the prompt before the extraction call. If the user says "3 of the blue widget," you retrieve the actual blue-widget products from your catalog, list them with their real SKUs, and ask the model to pick. Now the model is matching against a closed set you supplied, not recalling a SKU shape from training. The same `OrderItem` model and validator stay in place as a backstop, but they fire far less often.
The question becomes where that retrieved context comes from. Most teams reach for a vector database and embed everything. That works for genuinely unstructured text, but it is the wrong default for catalog data, articles with schema, anything with fields. "3 of the blue widget" has a structural component (color is a field, in-stock is a publication state) that pure embedding similarity resolves badly. You want to query on the structure, filter on the fields, and only fall back to semantic matching when the query is genuinely fuzzy.
Retrieval beats correction
Connect the validator to live structured content via Context MCP
This is where Sanity Context becomes the source of truth your validators and your prompts read from. The fastest way in is Context MCP, the hosted, read-only MCP endpoint. Your agent attaches it as an MCP server and gets schema-aware tools out of the box: it can read your content model, run GROQ queries, and traverse references without you hand-writing a query API. For an Instructor pipeline that already speaks function calling, wiring an MCP client to feed the same context is a small addition.
If you want full control over the query, the second path is a thin tool that runs a typed GROQ query directly. For grounding a SKU validator, a structured GROQ query is exactly right, because catalog data is structured content. You filter on the fields that matter (color, publication state) and project only what you need. The result is a small list of real products you can both inject into the prompt and validate against, refreshed on every request without a sync job.
The read-only constraint is the point, not a limitation. The agent reads facts to ground its output. It never mutates your content through this path. Writes go through Agent Actions, governed separately. For your extraction pipeline that is the correct shape: the model proposes structured data, validated against live content it is not allowed to corrupt.
GROQ query backing the validator
Structured predicates filter on real fields and publication state. No embeddings needed for catalog lookups.
*[_type == "product" && color == $color && !(_id in path("drafts.**"))]{
sku,
title,
"inStock": stock > 0
}When the query is fuzzy, reach for hybrid retrieval
Not every lookup is a clean structural filter. "The widget the customer complained about last week, the squeaky one" has no field called `squeaky`. This is where retrieval gets genuinely hybrid, and where it is worth being precise, because hybrid is widely misframed as "embeddings everywhere."
In practice the heavy majority of grounding calls in a production Instructor pipeline are structured: GROQ filters and schema lookups. Semantic search is a small slice you reach for when the query has a fuzzy component that predicates cannot express. In Sanity Context, embeddings are opt-in and off by default, and many projects shipping on Context MCP never turn them on. When you do, the discipline is one GROQ query that combines structural predicates, BM25 keyword matching, and optional semantic similarity, scored together, rather than a separate vector store you keep in sync.
The operators are `text::query($queryText)` for BM25 keyword match, `text::semanticSimilarity($queryText)` for vector similarity (note it takes the query text, not an embedding field), and `score()` to combine weighted signals into a `_score` you order by. The structural filter stays where it belongs, as a predicate inside the `*[ ... ]`, so "in stock, squeaky-adjacent description" resolves as one query. For the genuinely unstructured corpus, support tickets, PDFs, the customer's actual complaint text, Knowledge Bases is the surface that turns those messy sources into ordered documents you can retrieve from.
Hybrid retrieval in one GROQ query
Structural predicate filters stock. BM25 and semantic similarity score the fuzzy match. Embeddings stay optional.
*[_type == "product" && stock > 0]
| score(
boost(description match text::query($queryText), 0.5),
text::semanticSimilarity($queryText)
)
| order(_score desc)[0...5]{
sku,
title,
_score
}Why a structured-content source belongs here, not a JSON blob
You could solve all of this with a JSON file of valid SKUs and a re-sync job. Plenty of teams do, until the catalog grows, the editorial team needs to change a product without a deploy, and the sync job drifts so the model validates against stale truth. The reason to put grounding data in Sanity rather than a flat file is that the same content is edited, versioned, governed, and previewed by humans, then read by your agent through one read-only endpoint.
This is the institutional case for Sanity as the Content Operating System for the AI era. Your Instructor pipeline is one consumer of the content. The catalog, the brand voice rules, the approved response snippets, and the agent instructions all live as governed content, edited in the Studio, staged through Content Releases, and exposed to the agent through Context MCP. The model never reads stale data because there is no separate copy to go stale.
For your day-to-day Instructor work the practical takeaway is narrow. Keep your Pydantic models and your `field_validator` correction loop. Stop backing them with hardcoded sets or drifting JSON. Point the validators and the pre-extraction context at live structured content through GROQ over Context MCP, reach for hybrid retrieval only when the query is genuinely fuzzy, and route unstructured complaint text through Knowledge Bases. Your structured outputs survive production because they were grounded in something real before they were typed.