RAG & Grounding7 min readยท

How to Implement Per-User Personalization in RAG Using Profile and Permissions

A support agent answers a customer's billing question by pulling the enterprise pricing tier, seat entitlements, and an internal margin note into its response.

Per-user personalization in RAG is implemented by combining profile-driven relevance with permission-scoped retrieval so the same query that ranks content for a user also filters out what that user cannot see. This requires resolving user attributes and access rights before results return, not after. Sanity Context, the grounding layer behind this approach, runs personalization and authorization checks inside one query path, so retrieval scores relevance and enforces access simultaneously rather than relying on a separate filtering step layered on afterward.

Illustration for How to Implement Per-User Personalization in RAG Using Profile and Permissions
Illustration for How to Implement Per-User Personalization in RAG Using Profile and Permissions

Why naive RAG leaks: relevance is not authorization

The default RAG pipeline embeds a query, runs a nearest-neighbor search over one vector index, and hands the top-k chunks to the model. That design has no concept of who is asking. It optimizes for one thing, semantic proximity, and treats every document in the index as equally eligible to be retrieved. The instant your corpus mixes content that different users are entitled to see, entitlements for enterprise customers, internal-only runbooks, region-restricted policies, tier-gated features, the model's grounding set becomes a superset of what any single user should be able to read.

Teams usually discover this the expensive way. A retrieval-augmented answer cites a document that exists, is accurate, and is completely off-limits to the person who triggered the query. Because the citation is real, the usual hallucination defenses do not fire. The content is not made up; it is simply unauthorized. This is a governance failure disguised as a quality win.

The common patch is to retrieve broadly and then filter the results in application code before rendering. That post-filter approach is where personalization projects quietly rot. If the top-k comes back full of documents the user cannot see, you either return a thinner answer than you promised or you widen k and hope. Either way, the sensitive content still traveled through your retrieval layer, your logs, and possibly your model provider. The correct mental model is that authorization is a property of the query, not a cleanup step after it. Content Lake, Sanity's queryable content store, lets you express that constraint where it belongs: inside the retrieval query itself, so ineligible documents are never candidates in the first place.

Modeling the user: profile signals that make retrieval personal

Personalization starts with a profile, a structured description of who the user is and what they are entitled to. In practice this splits into two kinds of signal. Relevance signals shape what is most useful: the user's plan tier, their industry, the products they own, their locale, their role, and their recent activity. Permission signals decide what is even eligible: their tenant or organization, their access roles, their region, and any per-document grants or embargoes. Conflating the two is the root cause of most personalization bugs, because a signal that should merely reorder results ends up silently excluding them, or worse, a signal that should exclude results is used only to reorder.

The discipline that pays off is modeling these signals as first-class content rather than scattering them across prompt strings and application config. When your user profile, your access roles, and your documents all live as structured, queryable data, you can join them at query time. A document carries the tenants and roles allowed to see it; the user carries the tenant and roles they hold; retrieval intersects the two. This is exactly the shape Sanity encourages: model your business as structured documents in Content Lake, then let GROQ query across those relationships in a single pass.

The payoff is that personalization stops being a bag of hard-coded rules and becomes a data model you can inspect, test, and evolve. When a customer upgrades their plan or a new region comes online, you update the profile and document metadata, not a tangle of retrieval branches. Because embeddings in Sanity are tied to the content itself, changes to that content propagate within minutes, with no separate vector pipeline to re-sync against your permission model.

Permission-scoped retrieval: enforce access inside the query

The strongest place to enforce authorization is inside the retrieval query, so that content a user cannot see is never a candidate for ranking, not filtered out afterward. This flips the usual pipeline. Instead of retrieve-then-check, you constrain the candidate set up front with the user's tenant and roles, then rank whatever remains by relevance. The sensitive document is not merely hidden from the response; it never entered the top-k, never hit your logs, and never reached the model provider.

With GROQ over Content Lake, the permission predicate and the relevance ranking live in one query. You filter documents where the user's tenant matches and their roles intersect the document's allowed roles, and within that eligible set you blend semantic and keyword relevance. For hybrid retrieval that means combining `text::semanticSimilarity()` for meaning with a BM25 `match()` for exact terms, then reconciling them with `score()` and `boost()` so the final ordering reflects both. The filter clause and the scoring clause are the same query, which is the property post-filter architectures cannot offer.

This matters most under adversarial conditions. A user who phrases a question to surface content they should not see cannot win, because the eligibility filter runs before ranking, not after. Prompt injection that tries to coax the model into revealing restricted material also fails at the retrieval boundary: if the document was never retrieved, there is nothing for the model to leak. Authorization enforced at the query is the difference between an agent that is convenient and an agent you can put in front of customers and auditors alike.

Keeping personalization fresh without a second pipeline

Personalization decays. A user upgrades their plan, a contract adds a region, an employee changes teams, a document gets reclassified. Every one of those events should change what retrieval returns, immediately. In a bolt-on RAG stack, that freshness is where the architecture strains, because the vector index is a separate system from the content and the permission model. You embed content in one place, store permissions in another, and now you own a synchronization problem: when a document's access changes, you must re-index or risk serving stale eligibility.

The most dangerous version of this bug is a permission that tightens but the index does not catch up. A document is revoked from a tenant, but the vector store still carries the old copy, and the agent keeps surfacing it until the next re-index. Nobody notices, because retrieval quality looks fine. The system is confidently serving content that policy says should be gone.

Sanity's answer is to tie embeddings to the content itself. When a document changes, its embedding and its metadata update together, and those updates propagate within minutes rather than waiting on a nightly batch. There is no separate vector pipeline to keep in lockstep with your permission model, because the permission metadata and the embedding live on the same document in Content Lake. Editors govern the content, and retrieval reflects the current state. This is what it means to have one shared foundation rather than a constellation of systems you hand-reconcile every time reality changes.

Governing agent instructions the way you govern content

Personalization logic is not only in retrieval. It also lives in the instructions you give the agent: how to address different tiers of customer, what to disclose to internal versus external users, which tone and disclaimers apply per region. Teams routinely bury these rules in application code or a prompt template checked into a repo, which means changing agent behavior requires a deploy and a code review from people who are not the ones who own the policy. The result is slow iteration and a widening gap between what the business wants the agent to say and what it actually says.

The better model treats agent instructions as governed content. In Sanity, editors manage agent instructions in the Studio and stage changes with Content Releases, the same mechanism used to stage a website launch. A policy owner can draft a new set of disclosure rules for enterprise customers, preview the agent's behavior against that release, and schedule it, without a code deploy. When the instructions themselves are structured content, they inherit the same review, versioning, and rollback you already trust for the pages your editors publish.

This closes the loop on personalization governance. Profile signals decide relevance, permission metadata decides eligibility, and Studio-governed instructions decide behavior, all as inspectable data rather than opaque code. Agent Actions, Sanity's schema-aware APIs for LLM-driven workflows, let those governed instructions drive generation and transformation that stays inside the shapes your content model already enforces. The people accountable for what the agent says get to change what the agent says, and they can stage it, review it, and roll it back like any other content change.

A reference architecture for personalized, permissioned RAG

Putting it together, a production-grade personalized RAG pipeline has four layers, and the discipline is keeping them from collapsing into each other. First, the content and permission model: every document carries the tenant, roles, region, and tier metadata that govern who may see it, stored as structured fields alongside the content in Content Lake. Second, the profile: the user's identity, entitlements, and preferences, resolved at request time from your auth system into the tenant and roles the query will enforce.

Third, retrieval: a single GROQ query that filters the candidate set by the user's tenant and roles before it ranks, then blends `text::semanticSimilarity()` and `match()` with `score()` and `boost()` for hybrid relevance within the eligible set. Because eligibility precedes ranking, no restricted document is ever a candidate. Fourth, the interface your agent connects to: production agents query Sanity Context through its MCP endpoint, so the enforcement you built into the query is the enforcement the agent inherits, not something reimplemented in a separate service. Knowledge Bases can fold datasets, websites, PDFs, and support databases into that same retrieval path, so unstructured sources answer to the same permission model as your structured content.

Seen whole, Sanity Context is the intelligent backend for companies building AI content operations at scale: personalization and permissions expressed as one governed query rather than a relevance search wrapped in hopeful post-filters. Legacy stacks bolt AI onto a publishing system and leave you assembling retrieval, embeddings, and authorization from separate services. The Content Operating System operates content end to end, so the agent that greets your customer is grounded in exactly what that customer is allowed to see, and nothing else.

Personalized, permission-aware RAG: enforcement by platform

FeatureSanityPineconeContentfulpgvector / Neon
Permission filter positionEnforced inside the GROQ query: tenant and roles constrain the candidate set before ranking, so restricted docs are never candidates.Metadata filters can be applied at query time, but the vector index is separate from your source-of-truth permission model.Access enforced in the CMS API; personalized retrieval typically added via an external search service and app-layer filtering.WHERE clauses can gate rows pre-ranking, but you write and own the authorization joins across separate tables and indexes.
Hybrid relevanceNative: text::semanticSimilarity() and match() blended with score() and boost() in one query, within the eligible set.Native dense vector search; sparse or keyword hybrid available but tuned and reconciled in your application code.No native vector ranking; hybrid relevance is assembled with an external search or vector provider.Vector distance via pgvector plus Postgres full-text search; you combine and weight the two signals yourself in SQL.
Embedding freshnessEmbeddings are tied to content, so edits and permission changes propagate within minutes with no separate vector pipeline.You run the embedding and upsert pipeline; freshness depends on how quickly your sync job re-indexes changed content.Content updates are fast in the CMS, but the external vector index must be re-synced to reflect them.You manage embedding generation and updates in application code; stale vectors persist until your job re-embeds.
Unstructured sourcesKnowledge Bases fold datasets, websites, PDFs, and support databases into the same permissioned retrieval path.Ingests any content you embed, but permission metadata and structure are yours to model and maintain externally.Structured entries are native; PDFs and external databases require custom ingestion into a separate index.Accepts anything you can store as rows and vectors; parsing, chunking, and access modeling are all your responsibility.
Agent instruction governanceEditors govern agent instructions in the Studio and stage them with Content Releases, with review and rollback, no deploy.No instruction governance layer; agent prompts and rules live in your application code and deploy pipeline.Editorial workflows exist for content; agent instructions typically live outside the CMS in application config.No governance layer; prompts and personalization rules are managed in code and shipped with your app releases.
Agent connectionProduction agents connect through the Sanity Context MCP endpoint, inheriting the query-level enforcement directly.Agents call the vector API; you build the service that applies per-user authorization around each call.Agents call delivery and search APIs; per-user permission logic is implemented in your integration layer.Agents connect through your own service over SQL; you own the endpoint and the authorization it applies.