Phase 6: RAG Systems & Knowledge Grounding · 60 min · LangChain · Pinecone · pgvector
The Concept
Why Naive RAG Breaks Access Control
A standard RAG pipeline works like this:
- All documents are chunked and embedded into a shared vector store
- A user query is embedded and compared against all chunks
- The top-K most similar chunks are retrieved and passed to the LLM
- The LLM generates an answer from those chunks
The problem: Step 2 searches all chunks regardless of who the user is. The vector store has no concept of permissions. If a chunk is semantically similar to the query, it gets retrieved — even if the user has no right to see it.
Two Approaches to Access Control
Pre-retrieval filtering (correct): Filter the vector store search to only include documents the user can access. The retrieval never sees unauthorized chunks. The LLM never sees them. No leakage is possible.
Post-retrieval filtering (dangerous): Retrieve top-K chunks normally, then filter out chunks the user can't access before passing to the LLM. This is better than nothing but has critical failure modes.
You implement post-retrieval filtering: retrieve top-10 chunks, remove any the user can't access, pass the rest to the LLM. A user with access to only 10% of documents asks a question whose top-10 results are ALL from restricted documents. What happens?
0
The Metadata Filtering Pattern
Pre-retrieval filtering works by attaching permission metadata to each chunk at indexing time, then applying a metadata filter at query time.
# At indexing time: attach permission metadata to each chunk
chunks = [
{
"text": "The CEO's total compensation for 2025 was $15.2M...",
"metadata": {
"doc_id": "comp-committee-2025",
"department": "HR",
"classification": "confidential",
"allowed_roles": ["hr_director", "cfo", "board_member"],
"allowed_users": ["alice@company.com", "bob@company.com"],
}
},
{
"text": "The company holiday party will be on December 15...",
"metadata": {
"doc_id": "holiday-party-2025",
"department": "HR",
"classification": "public",
"allowed_roles": ["*"], # Everyone
"allowed_users": ["*"],
}
}
]
# At query time: build a filter from the user's permissions
def build_access_filter(user_roles: list[str], user_email: str) -> dict:
"""Build a metadata filter that only matches documents the user can access."""
return {
"$or": [
{"allowed_roles": {"$in": user_roles}},
{"allowed_users": user_email},
{"allowed_roles": "*"}, # Public documents
]
}
# Query the vector store with the access filter
results = vector_store.similarity_search(
query="What is the CEO's salary?",
k=5,
filter=build_access_filter(
user_roles=["intern"],
user_email="intern@company.com"
)
)
# The intern gets zero results — the comp committee report is filtered out
# at the vector store level. The LLM never sees it.
Multi-Tenant RAG Architecture
For SaaS products where multiple organizations share the same RAG infrastructure:
# Each chunk gets a tenant_id at indexing time
chunk = {
"text": "...",
"metadata": {
"tenant_id": "org_abc123",
"doc_id": "doc_456",
"allowed_roles": ["admin", "editor"],
"allowed_users": ["user@org_abc123.com"],
}
}
# At query time, tenant_id is a hard filter — never optional
def query_rag(user_query: str, user_tenant: str, user_roles: list, user_email: str):
filter = {
"$and": [
{"tenant_id": user_tenant}, # Hard tenant isolation
{"$or": [
{"allowed_roles": {"$in": user_roles}},
{"allowed_users": user_email},
{"allowed_roles": "*"},
]}
]
}
return vector_store.similarity_search(user_query, k=5, filter=filter)
Permission-Aware Chunking Strategy
A subtle issue: if a document has mixed classification levels (e.g., a financial report with a public summary and confidential details), chunking can leak information.
# BAD: Chunk the entire document with one permission level
# The public summary chunks and confidential detail chunks all get "confidential"
# Users who should see the summary can't access any of it
# GOOD: Split by section, assign per-section permissions
sections = [
{"text": report.executive_summary, "classification": "public"},
{"text": report.financial_details, "classification": "confidential"},
{"text": report.appendix_tables, "classification": "internal"},
]
for section in sections:
chunks = chunk_text(section["text"], chunk_size=512)
for chunk in chunks:
chunk["metadata"] = {
"doc_id": report.id,
"classification": section["classification"],
"allowed_roles": get_roles_for_classification(section["classification"]),
}
vector_store.add(chunk)
Your RAG system indexes a 50-page financial report. The first 2 pages are a public executive summary. Pages 3-50 are confidential financial details. You chunk the entire document with chunk_size=512 and assign all chunks the "confidential" classification. What problem does this create?
0
Using pgvector for Access-Controlled RAG
PostgreSQL with pgvector is ideal for access-controlled RAG because you get SQL's filtering power alongside vector similarity search:
-- Create a table with vector column and permission columns
CREATE TABLE document_chunks (
id UUID PRIMARY KEY,
content TEXT,
embedding VECTOR(1536),
tenant_id UUID NOT NULL,
doc_id UUID NOT NULL,
classification TEXT NOT NULL DEFAULT 'internal',
allowed_roles TEXT[] NOT NULL DEFAULT '{}',
allowed_users TEXT[] NOT NULL DEFAULT '{}'
);
-- Create a GIN index on the permission arrays for fast filtering
CREATE INDEX idx_chunks_roles ON document_chunks USING GIN (allowed_roles);
CREATE INDEX idx_chunks_tenant ON document_chunks (tenant_id);
-- Query with access control: vector similarity + permission filter in one query
SELECT content, embedding <=> $1 AS distance
FROM document_chunks
WHERE
tenant_id = $2 -- Hard tenant isolation
AND (
$3 = ANY(allowed_roles) -- User has one of the allowed roles
OR $4 = ANY(allowed_users) -- User is in the allowed users list
OR '*' = ANY(allowed_roles) -- Document is public
)
ORDER BY embedding <=> $1
LIMIT 5;
-- $1 = query embedding, $2 = tenant_id, $3 = user_role, $4 = user_email
The key advantage: the permission filter and the vector similarity search happen in a single database query. PostgreSQL's query planner optimizes the combined operation, and you never retrieve unauthorized chunks.