Skip to content
Forward Deployed

Forward Deployed
Lessons and contents

Enterprise RAG on a Governed Lakehouse

By Sourav Banerjee · Last updated 12 Oct 2026 · Report an issue

8 min read

Buying the platform removes most of the code to write and defend, but the one gap it leaves in access control decides the whole physical design.

On a lakehouse, most of a hand-built enterprise RAG system becomes platform primitives, while the permission policy stays yours. Buy the undifferentiated layers, build the customer-specific logic, and design around the one fact the platform will not do for you: the vector index does not enforce row-level security.

Objectives:

  • decide what to build and what to buy across an AI platform stack;
  • explain the two Vector Search facts and the forced two-object design they create;
  • enforce access in two layers, with on-behalf-of-user identity making the second one real;
  • test the enforcement point with a SQL assertion and name the honest trade-offs.

Buy the Undifferentiated Layers

The principle is simple: buy the undifferentiated layers and build the customer-specific logic.

Layer Default Build only when
Identity and login The customer's own identity provider Never; someone already owns this solved problem
Secrets storage A managed vault Almost never; "we built our own" security infrastructure is a red flag
Search and vector index A managed search product Self-host only at very large scale, or when data legally cannot leave an environment
Monitoring An existing, standards-compatible platform A thin custom layer for missing domain fields, on top, not instead
Permission rules, retrieval tuning, prompt design, the customer's workflows Always build These encode the customer's data, policy and process

The line is not "build everything to prove capability" or "buy everything to move fast". It is whether a component encodes something specific to this customer's business.

Map the Hand-Built System to Primitives

The handbook first built enterprise RAG by hand: a policy engine, an identity file, a BM25 index, a reranker, a trace layer and an evaluation harness. On Databricks most of it maps to a primitive.

Hand-built Databricks primitive
ACL manifest synced into a SQLite catalogue Delta columns in Unity Catalog
The Python rules engine Unity Catalog row filters and column masks (SQL functions)
A JSON file of personas SCIM groups and is_account_group_member()
A post-retrieval re-check in Python Unity Catalog enforcing the row filter, on behalf of the user
A PII redaction regex A Unity Catalog column mask
ChromaDB Mosaic AI Vector Search (Delta Sync index)
BM25 plus hand-written fusion query_type="HYBRID"
An LLM reranker databricks_reranker, which reranks the top 50
External embeddings databricks-gte-large-en, computed by the platform
A LangGraph script LangGraph inside Mosaic AI Agent Framework
A custom trace module MLflow Tracing into Unity Catalog tables
The evaluation harness mlflow.genai.evaluate() with custom scorers
"Who read what?" and cost attribution system.access.audit and system.billing.usage

Design Around the Two Facts

Fact 1: Vector Search does not enforce Unity Catalog row filters or column masks. The documentation says row and column permissions are not supported, and suggests application-level ACLs through the filter API.

Fact 2: a Delta Sync index will not even build on a governed table. This is not on the limitations page; it was found by trying. With a row filter attached to the chunks table, index creation fails with an error that the table cannot have both row or column security and online materialised views.

Fact 1 alone would let the naive design be built and merely be insecure. Fact 2 stops it building at all. The reason is that the index is a derived copy. The sync pipeline reads the source table with its own identity and writes vectors into a serving system with no concept of users. There is no query-time user against whom to evaluate group membership.

An operational trap surfaced the same way. CREATE OR REPLACE TABLE does not detach an attached row filter or mask. A half-finished run leaves the table permanently un-indexable until the filter and mask are dropped. Make setup idempotent.

Split the Data Into Two Objects

The forced design has two objects.

   kb.chunks            UNGOVERNED base table, SELECT granted ONLY to the sync principal
      ├──────────>       VECTOR SEARCH INDEX      layer ①: ACL applied by the query filter
      └──────────>       kb.chunks_secure         GOVERNED dynamic view, SELECT for humans and agents
                                                  layer ②: ACL and PII masking by Unity Catalog

Because the base table carries no policies, granting anyone SELECT on it bypasses the whole access model. Locking it to the pipeline identity is load-bearing, not hygiene.

REVOKE SELECT ON TABLE kb.chunks        FROM `analysts`;
GRANT  SELECT ON VIEW  kb.chunks_secure TO   `analysts`;

A dynamic view carries the row rules and the column masking in one object. Here it is the only mechanism that coexists with the index.

Enforce in Two Layers That Do Not Trust Each Other

Layer 1 is the Vector Search filter. It is compiled from the caller's groups and passed on every query, so unauthorised vectors are never scored or returned. It is an optimisation and a first line of defence.

Layer 2 re-reads the returned chunk IDs from the governed view, on behalf of the user. Unity Catalog applies the row filter and column mask itself. This is the authoritative decision.

In the hand-built version, Layer 2 was code that had to be proven correct with tests. Here it is Unity Catalog, the engine that already governs every dashboard and notebook in the company. The text sent to the model always comes from Layer 2, never from the index. The index supplies candidate IDs and ranking; the governed view supplies content the user may read.

Enforcement order changes slightly. By hand, enforcement ran before reranking. Here Layer 1 precedes the reranker and Layer 2 follows it. Anything Layer 2 drops leaves a gap in the context, which is the right failure direction: material vanishes rather than leaks.

Lay the Data Out as a Medallion

Bronze holds raw documents in a Unity Catalog volume with source metadata in Delta; PDFs go through ai_parse_document. Only admins read it.

Silver holds one row per chunk, and the ACL columns live here: tenant_id, source_system, sensitivity and sensitivity_lvl, region, contains_pii, need_to_know and validity dates. It also holds one BOOLEAN per group, such as grp_public and grp_support_t3. Change data feed is on, which tells the index what changed and makes incremental re-embedding cheap.

Gold holds the Vector Search index and the governed view. It must be a view, not a filter on the base table, because of Fact 2.

Encode Groups So the Index Can Filter

Vector Search filters work on scalar columns. There is no array-containment operator, so an allowed_groups array cannot be filtered directly. One BOOLEAN column per group works when the group set is bounded, tens rather than thousands.

# Storage-Optimized endpoint: SQL-string filters
filters = ("tenant_id = 'meridian' AND sensitivity_lvl <= 2 "
           "AND region IN ('GLOBAL', 'EU') "
           "AND (grp_public = true OR grp_support_t3 = true OR grp_engineering = true)")

Standard endpoints take dictionary filters, and their multi-column OR is positional. That cost two failed runs against a live index. The value must be an array with exactly one element per OR clause.

{"grp_a OR grp_b": [True, True]}   ->  grp_a = True OR grp_b = True   ✅
{"grp_a OR grp_b": True}           ->  400: "input must be an array"   ❌
{"grp_a OR grp_b": [True]}         ->  400: length does not match the clauses ❌

Generate the clause and the array from one list so they cannot drift. The alternative, one row per chunk and group, supports unbounded groups but multiplies vectors, the expensive thing. Use it only when group count breaks the BOOLEAN encoding.

Standard endpoint Storage-Optimized endpoint
Latency 20–50 ms 300–500 ms
Capacity ~320M vectors 1B+
Cost Higher ~7× lower
Filters Dictionary SQL string, reviewable by a security person

For an internal assistant where a 2 s generation dominates, 300 to 500 ms of retrieval is invisible. Choose Storage-Optimized and spend the savings on reranking.

Write the Policy Once, in SQL

The governed layer reads an entitlements table, not hard-coded groups. The whole policy becomes one SQL function. It checks admin bypass, then six rules for the current user: tenant isolation, a clearance ladder, data residency, embargo and expiry evaluated at query time, need-to-know compartments, and a block on commercial sources for external users. On governed tables that are not the index source, attach it as a row filter. For the index source, the same predicate sits in the secure view's WHERE, beside the PII mask.

The policy now protects the table for every reader: the agent, a notebook, a dashboard and the SQL editor. By hand, it only protected callers who went through the application code.

PII becomes a column mask: a regexp_replace on content when contains_pii is set and the caller is not a PII reader. A column mask is a deterministic governance policy. An AI masking function must never be what stands between a user and PII. One gotcha carries over from the hand-built version: a \w inside a Spark SQL string dies silently in the escaping chain. Use explicit character ranges and assert on real data.

Group membership comes from SCIM-synced account groups, so the identity provider stays the source of truth. Live revocation is free: remove someone in the identity provider, SCIM syncs, and the next query drops those rows. There is no reindex, no cache and no code.

Rate your confidence and keep notes on the module page.