You wired up a LlamaIndex `VectorStoreIndex` over your content, the demo retrieval looked great, and then a user asked "show me the pricing page changes from last quarter that are still in draft." Your top-k semantic search returned three published blog posts about pricing strategy. None of them were the draft page. The query had a structural component, a date range, a publication state, a document type, and cosine similarity has no idea those constraints exist. This is the failure mode that pure vector retrieval can't fix by tuning `similarity_top_k`.
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, websites, and support data. The point for a LlamaIndex developer: you can register that retrieval as a tool your agent calls, and the query can carry both meaning and structure.
This article shows how to diagnose retrieval failures that are structural, not semantic, how LlamaIndex's `FunctionTool` and query engine abstractions let you wrap an external retriever, and how to back that tool with schema-aware GROQ so a single query resolves "draft pricing pages from last quarter" correctly.
Why top-k semantic search returns the wrong node
Start by being honest about what `VectorStoreIndex` actually does. When you call `index.as_retriever(similarity_top_k=5)`, LlamaIndex embeds the query string, runs an approximate nearest-neighbor search over your node embeddings, and returns the five closest vectors. That is the whole mechanism. There is no `WHERE status = 'draft'`, no date filter, no join to an author record. Embeddings encode rough topical similarity, and that is all they encode.
So when a query says "draft pricing pages from last quarter," the embedding captures "pricing pages" reasonably well and silently drops "draft" and "last quarter," because those are predicates, not topics. The retriever happily returns the most pricing-flavored nodes it can find, and most of them are published, and none of them respect the date window. You see a confident, fluent answer built on the wrong documents.
The usual first reaction is to crank `similarity_top_k` to 20 and hope the right node is in there somewhere. Sometimes it is, but now you have stuffed twenty mostly-irrelevant nodes into the context window, and the model has more noise to ignore. You traded a precision problem for a cost-and-distraction problem. The root issue is unchanged: a date range and a publication state are not things cosine distance can represent. You need a retriever that filters before it ranks.
Metadata filters help, until your filters live in another system
LlamaIndex does give you a real tool for part of this: metadata filters. If you attach metadata to your nodes at ingest time, you can constrain the search before the vector comparison runs. This is the right instinct, and for a self-contained index it works.
Metadata filters are only as fresh as your last ingest
Metadata-filtered retrieval in LlamaIndex
from llama_index.core.vector_stores import (
MetadataFilters,
MetadataFilter,
FilterOperator,
)
filters = MetadataFilters(
filters=[
MetadataFilter(key="status", value="draft", operator=FilterOperator.EQ),
MetadataFilter(key="doc_type", value="pricingPage", operator=FilterOperator.EQ),
]
)
retriever = index.as_retriever(
similarity_top_k=5,
filters=filters,
)
nodes = retriever.retrieve("pricing page changes")The real cost: keeping the snapshot in sync
The code above looks clean, but it hides a maintenance problem that grows with your content. Every metadata field you want to filter on, `status`, `doc_type`, `publishedAt`, `author`, `productVariant`, has to be extracted from your source system, attached to each node, and re-synced whenever it changes upstream. You end up owning a pipeline whose only job is to copy structured fields out of a content system and into vector-store metadata so you can filter on them later.
That pipeline is where things rot. References are the worst offenders. If an article points to an author document and you want to filter by `author.team == 'platform'`, you have to denormalize the author's team onto every article node at ingest, then remember to re-denormalize when the author changes teams. Miss one and your filter quietly returns stale results. Reference traversal, the thing a relational or document database does in one query, becomes a fan-out of copied fields that you hand-maintain.
This is the moment to ask a different question. Instead of copying your structured data into vector metadata so a vector store can approximate a filtered query, what if the retriever ran the actual structured query against the actual source of truth, and only reached for embeddings on the part of the query that is genuinely semantic? That is a hybrid retrieval discipline: structural predicates do the filtering, keyword and optional vector similarity do the ranking, and you stop owning a sync job.
Schema-aware retrieval against the source of truth
This is where Sanity Context fits into a LlamaIndex stack. Rather than indexing a snapshot, you query the live dataset with GROQ, Sanity's query language, which expresses structural predicates and semantic ranking in one expression. The structural part, document type, publication state, date range, reference traversal, lives in the filter `*[ ... ]`. The ranking part uses `score()` with `text::semanticSimilarity()` and `text::query()` for keyword matching. The filter runs first, so semantic ranking only ever operates on documents that already satisfy your constraints.
Most retrieval calls never touch embeddings
Hybrid GROQ: structural filter plus semantic ranking
*[_type == "pricingPage" && status == "draft" && publishedAt > $since]
| score(
boost(title match text::query($queryText), 2),
text::semanticSimilarity($queryText)
)
| order(_score desc)
[0...5]
{ _id, title, status, publishedAt, _score }Wrapping it as a LlamaIndex tool
Your agent doesn't call GROQ directly; it calls a tool, and LlamaIndex's `FunctionTool` is the clean way to expose one. The two integration paths are: attach Context MCP, the hosted read-only MCP endpoint, and let the agent discover schema-aware tools automatically, or write a thin `FunctionTool` wrapper around a single typed GROQ query when you want full control over the query shape. MCP is the fastest way in; the custom tool is the path when you want to pin exactly what the agent can ask for.
A FunctionTool that runs schema-aware retrieval
import os, httpx
from datetime import datetime, timedelta
from llama_index.core.tools import FunctionTool
from llama_index.core.agent import ReActAgent
from llama_index.llms.openai import OpenAI
GROQ_QUERY = (
'*[_type == "pricingPage" && status == "draft" '
'&& publishedAt > $since]'
'| score(text::semanticSimilarity($queryText)) '
'| order(_score desc)[0...5]'
'{_id, title, status, publishedAt, _score}'
)
def retrieve_draft_pages(query_text: str, days: int = 90) -> str:
"""Retrieve recent draft pricing pages matching the query."""
since = (datetime.utcnow() - timedelta(days=days)).isoformat() + "Z"
url = f"https://{os.environ['SANITY_PROJECT']}.api.sanity.io/v2024-01-01/data/query/production"
resp = httpx.get(url, params={
"query": GROQ_QUERY,
"$queryText": f'"{query_text}"',
"$since": f'"{since}"',
})
return resp.json()["result"]
tool = FunctionTool.from_defaults(fn=retrieve_draft_pages)
agent = ReActAgent.from_tools([tool], llm=OpenAI(model="gpt-4o"))
print(agent.chat("What pricing page edits are pending review?"))Where each piece of the stack actually belongs
Schema-aware GROQ retrieval is the right answer for structured content, your catalog, your articles, your pricing pages, anything that has a schema and reference relationships. For unstructured sources, PDFs, marketing websites, support ticket exports, reach for Knowledge Bases, the second Sanity Context surface, which turns a messy corpus into ordered documents with a table of contents instead of forcing it through a structural query language that was built for typed data.
Keep two boundaries clear. First, Context MCP is read-only. An agent can query, traverse references, and rank results, but it cannot write back through MCP; mutations go through Agent Actions, a separate path. If your tool needs to flip a page to published, that is not a retrieval call. Second, not everything belongs in Sanity. High-volume, machine-generated embeddings that need no editorial governance still have a home in a dedicated vector store like Pinecone or Qdrant, and LlamaIndex is happy to route across both. The win is that your editorial content, the stuff humans review and version, gets queried at the source instead of through a hand-maintained snapshot.
Sanity is the Content Operating System for the AI era, an intelligent backend for teams building AI content operations at scale, which is why the retrieval tool you give LlamaIndex can carry structure, freshness, and governance instead of just topical similarity. Your agent stops guessing from stale metadata and starts asking the source of truth a question it can actually answer.