Agent Frameworks

Build a hybrid retrieval LangChain retriever with Sanity Context

LangChain

Composable framework for building LLM apps and agents in Python and JavaScript, with retrievers, chains, and tool-calling agents at its core.

Visit LangChain

Your LangChain RAG chain works in the notebook and falls apart in production. A user asks "what did we publish about pricing changes last quarter" and your vector retriever returns a draft from two years ago, because cosine similarity has no idea what "last quarter" means and no idea the document was never published. You stuff more context into the prompt, tune `k`, swap embedding models, and the structural misses keep coming.

The fix is not a better embedding model. The fix is putting the structural part of the query where it belongs: in a real query engine, not in vector space. That is where Sanity Context comes in. 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. Knowledge Bases is the second surface, for unstructured sources like PDFs and websites.

This article shows how to wrap that retrieval inside a custom LangChain `BaseRetriever`, so date ranges, authorship, and publication state run as predicates while keyword and optional semantic scoring run alongside them, all resolved in one GROQ query before a single document reaches your chain.

Why your vector retriever returns the wrong document

Start with the failure, because it is specific. A LangChain `VectorStoreRetriever` does one thing well: it finds documents whose embeddings sit near the query embedding in vector space. That is genuinely useful for fuzzy semantic matches, "posts about onboarding friction" pulling a doc titled "why new users churn in week one". It is useless for anything structural.

Consider the query "pricing changes we published last quarter." Three constraints hide in that sentence. "Pricing" is semantic, embeddings handle it. "Published" is a state, a document either has a publish date in the past or it does not. "Last quarter" is a date range. Embeddings encode none of the last two. A draft titled "2022 pricing teardown" can sit closer in vector space to your query than the actual Q3 announcement, and your retriever will hand it to the LLM with full confidence.

The usual patches make it worse. Raising `k` from 4 to 20 floods the context window with near-misses and raises your token bill. Adding a metadata filter helps, but most LangChain vector stores apply metadata filters as a post-filter or a coarse pre-filter, and the scoring still happens in pure embedding space. You end up maintaining a parallel copy of every document's metadata inside your vector DB and keeping it in sync with the system of record. That sync job is where production RAG quietly rots: the embedding says published, the source says draft, and nobody notices until a customer quotes your agent back to you.

The retriever interface is the right extension point

LangChain gives you the seam you need: `BaseRetriever`. Every chain, agent, and `create_retrieval_chain` call in LangChain talks to a retriever through one method, so you can replace the vector store with anything that returns `Document` objects. You do not have to fight the framework to do this. You implement one method and the rest of your chain is unchanged.

In Python the contract is `_get_relevant_documents(self, query, *, run_manager)`. In JavaScript it is `_getRelevantDocuments(query, runManager)`. Return an array of `Document` instances with `pageContent` and a `metadata` dict, and LangChain's retrieval chain treats your custom source exactly as it would treat Chroma or Pinecone. The agent does not know or care where the documents came from.

This matters because it means the structural-versus-semantic split is your decision, made in code you own, not a limitation baked into a vector store class. You can run the structural constraints as real predicates against your system of record, run keyword and semantic scoring in the same place, and return ranked `Document` objects. The retriever boundary stays clean. Below is the skeleton, with the network call stubbed so the shape is clear before we fill it with a real query.

A custom BaseRetriever skeleton

Implement one method and every LangChain chain can use your source.

from langchain_core.retrievers import BaseRetriever
from langchain_core.documents import Document
from langchain_core.callbacks import CallbackManagerForRetrieverRun
from typing import List
import httpx

class SanityRetriever(BaseRetriever):
    project_id: str
    dataset: str = "production"
    api_version: str = "2025-02-19"

    def _get_relevant_documents(
        self, query: str, *, run_manager: CallbackManagerForRetrieverRun
    ) -> List[Document]:
        rows = self._run_groq(query)
        return [
            Document(
                page_content=row["body"],
                metadata={
                    "id": row["_id"],
                    "title": row["title"],
                    "score": row["_score"],
                    "publishedAt": row["publishedAt"],
                },
            )
            for row in rows
        ]

    def _run_groq(self, query: str) -> list:
        ...  # GROQ request, shown in the next section

One GROQ query: structural predicates plus scoring

This is the part the vector store cannot do. GROQ, Sanity's query language, lets you put structural constraints as predicates inside the filter and run relevance scoring in the same pass. The predicate runs first and narrows the candidate set to documents that actually satisfy the hard constraints. Only then does scoring rank what survives.

Read the query below from the inside out. The filter `*[ _type == "article" && publishedAt < now() && publishedAt > $since ]` is the structural part: real fields, real comparisons. A draft has no `publishedAt` in the past, so it never enters the candidate set. The date-range miss that embeddings could not catch is now a boolean that runs before anything else.

Then `score()` ranks the survivors. `boost(title match text::query($queryText), 2)` raises documents whose title matches the query keywords using BM25, weighted higher than a body match. `text::semanticSimilarity($queryText)` adds the embedding-based signal, the same fuzzy match your vector store gave you, but as one ingredient among several rather than the only one. `order(_score desc)` sorts by the combined `_score` that `score()` writes onto each result. Worth knowing: semantic similarity here is opt-in. Most projects running on Context MCP never enable embeddings, because structural predicates plus BM25 already answer the heavy majority of real queries. Reach for `text::semanticSimilarity()` when your failures justify it, not by default.

Hybrid retrieval in a single GROQ query

Structural filter narrows first, then score() ranks the survivors.

*[ _type == "article" && publishedAt < now() && publishedAt > $since ]
| score(
    boost(title match text::query($queryText), 2),
    body match text::query($queryText),
    text::semanticSimilarity($queryText)
  )
| order(_score desc) [0...8] {
    _id, title, body, publishedAt, _score
  }

Wiring the GROQ call into the retriever

Fill in the `_run_groq` stub from the skeleton. The structured GROQ API is plain HTTP, so you do not need a special SDK to call it from a LangChain retriever; an `httpx` POST works. You pass the query string and the parameters separately, which keeps `$queryText` and `$since` as bound params rather than string-concatenated into the query body. That separation is the same discipline as parameterized SQL: the user's input never becomes part of the query structure.

Notice what the retriever computes locally and what it delegates. The `$since` parameter, the boundary for "last quarter," is computed in Python from the current date. The semantic, keyword, and structural matching all happen server-side in the one GROQ request. Your retriever stays thin. It builds params, fires one request, and maps rows to `Document` objects.

The endpoint shown is the raw query API. If you would rather not hand-write GROQ and manage the HTTP call, Context MCP is the faster path: it is a hosted, read-only MCP endpoint that exposes schema reads, GROQ queries, reference traversal, and optional semantic search as schema-aware tools. A LangChain agent built with `langchain-mcp-adapters` attaches that MCP server and gets those tools without you writing the request layer at all. The custom retriever shown here is the second path, for when you want full control over the exact query and the ranking weights.

The _run_groq implementation

Bound params keep user input out of the query structure.

from datetime import datetime, timedelta, timezone

    def _run_groq(self, query: str) -> list:
        groq = (
            '*[ _type == "article" '
            '&& publishedAt < now() && publishedAt > $since ]'
            '| score('
            ' boost(title match text::query($queryText), 2),'
            ' body match text::query($queryText),'
            ' text::semanticSimilarity($queryText)'
            ') | order(_score desc) [0...8]'
            '{ _id, title, body, publishedAt, _score }'
        )
        since = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat()
        url = f"https://{self.project_id}.api.sanity.io/v{self.api_version}/data/query/{self.dataset}"
        resp = httpx.get(
            url,
            params={
                "query": groq,
                "$queryText": f'"{query}"',
                "$since": f'"{since}"',
            },
            headers={"Authorization": f"Bearer {SANITY_TOKEN}"},
        )
        resp.raise_for_status()
        return resp.json()["result"]

Dropping it into a retrieval chain

With the retriever done, the rest of your LangChain code does not change. `create_retrieval_chain` takes any `BaseRetriever`, so you swap your `VectorStoreRetriever` for `SanityRetriever()` and the chain reads documents the same way. The difference is invisible at the chain level and decisive at the result level: the documents arriving at the LLM already satisfy the structural constraints, so the model spends its context budget on relevant, published, in-range material instead of filtering out near-misses it should never have seen.

This is also where the read-only constraint of Context MCP earns its keep. The retriever reads. It cannot mutate your content, which means an agent loop wired to this source cannot accidentally publish, delete, or edit a document while answering a question. Writes in Sanity go through a separate path, Agent Actions, not through the retrieval surface. For a system reading from your editorial source of record, that separation is a feature, not a missing one.

The broader framing: Sanity is the Content Operating System for the AI era, the intelligent backend for teams building AI content operations at scale. The same dataset your editors model, version, and review in the Studio is the dataset your LangChain agent retrieves from, with no parallel metadata copy to sync and no drift between what was published and what the embedding thinks was published. The system of record and the retrieval source are the same thing.

Same chain, swapped retriever

create_retrieval_chain accepts any BaseRetriever unchanged.

from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

retriever = SanityRetriever(project_id="abc123")

prompt = ChatPromptTemplate.from_messages([
    ("system", "Answer using only the provided context:\n\n{context}"),
    ("human", "{input}"),
])
combine = create_stuff_documents_chain(ChatOpenAI(model="gpt-4o"), prompt)
chain = create_retrieval_chain(retriever, combine)

result = chain.invoke({"input": "pricing changes we published last quarter"})
print(result["answer"])

When to keep your vector DB, and when not to

None of this means rip out Pinecone or Chroma. The honest routing rule is about what kind of content you are retrieving, and you will often run more than one source.

For structured editorial content, articles, products, pages, anything with a schema and fields your queries care about, Sanity Context GROQ retrieval is the better source, because the structural constraints are first-class predicates and you query the same dataset your editors govern. For unstructured sources, PDFs, scraped websites, support transcripts, Knowledge Bases is the surface: it turns a messy corpus into well-ordered documents with a clear table of contents, then exposes that for retrieval. For a high-volume, machine-generated corpus that needs no editorial review, millions of log lines, raw event text, embeddings nobody will ever curate, a dedicated vector database still earns its place. Not everything belongs in your content system.

The pattern that holds up in production is hybrid in the precise sense: structural predicates plus BM25 plus optional embeddings, not embeddings everywhere. Vector search is one ingredient in good retrieval, not the whole recipe. The mistake the failing RAG chain made at the top of this article was treating it as the whole recipe. The fix was giving the structural part of the query a real place to run.

⚠️

Don't store chat history here

Sanity Context is for content you want versioned, governed, and reviewed: agent instructions, knowledge bases, approved responses, brand voice. It is not where you put ephemeral per-user chat history or session state. That belongs in Upstash, Redis, or a memory store wired to LangChain's memory interface. Keep the governed editorial source and the throwaway session state in different places.