How to classify, authorize, and protect sensitive data before it reaches an LLM, using PostgreSQL, pgvector, and Microsoft Presidio.
One thing that can easily be overlooked when building a RAG system is what happens to the retrieved data before it reaches the LLM.
We usually spend a lot of time improving retrieval quality, tuning embeddings, testing rerankers, and making sure the model receives the right context. But finding the right document doesn't necessarily mean the user should have access to it.
This becomes a serious problem in enterprise environments.
Take a financial institution where an internal AI assistant has access to HR documents, financial reports, customer information, and internal procedures. All these documents might be indexed in the same vector database.
A similarity search could return a confidential financial report to someone from customer support. The document is relevant to the question, but the employee isn't authorized to read it.
There is another problem that is easy to miss. Even when the employee has permission to access a document, that doesn't automatically mean the document can be sent to an external LLM provider.
These are two different things, and I think they should be handled separately in the architecture.
I would start by classifying documents during ingestion, before generating embeddings and storing them in the vector database.
For this example, we can use four classification levels:
| Classification | Example | Processing | | -------------- | ------------------------------------ | ------------------------------------- | | Public | Published company policies | External LLM generally allowed | | Internal | Internal procedures | Depends on company policy | | Confidential | Financial reports, contracts | Approved environments only | | Restricted | Sensitive customer data, credentials | External processing denied by default |
This is just an example. Classification rules depend on the organization and its data-handling policies.
Classification can come from existing document metadata, source-system labels, predefined rules, or content analysis. PII detection can help identify sensitive information, but it shouldn't be the only classification mechanism.
For example, a document containing an email address isn't necessarily confidential. On the other hand, an internal financial forecast can be highly confidential without containing any personal information.
Once a document is classified, that information needs to stay attached to it throughout the ingestion pipeline.
{
"document_id": "financial-report-q3",
"tenant_id": "company-001",
"classification": "confidential",
"allowed_departments": ["finance"],
"external_processing_allowed": false
}
When the document is split into chunks, each chunk should inherit the relevant classification and access metadata.
Otherwise, we might classify the original document correctly but lose that information during indexing.
Classification also needs to be updated when documents or company policies change.
I would keep classification, authorization, and model routing as separate parts of the pipeline.
flowchart TD
A[Document Sources] --> B[Ingestion]
B --> C[Classification + PII Detection]
C --> D[(PostgreSQL + pgvector)]
E[User] --> F[Authentication]
F --> G[Authorized Retrieval]
D --> G
G --> H[Data Policy Engine]
H --> I{Processing Allowed?}
I -->|External approved| J[PII Minimization]
J --> K[External LLM]
I -->|Private approved| L[Private Model]
I -->|Denied| M[Block]
K --> N[Output Validation]
L --> N
N --> O[Response]
The first check happens during retrieval. The database should return only documents the user is allowed to access.
The second check happens before calling the LLM. At this point, the application decides whether the retrieved information can be processed by the selected provider.
For example, someone from the finance department might have permission to read a confidential contract. But company policy might prohibit sending that contract to an external model.
In that case, the application needs to use an approved processing environment or reject the request.
I wouldn't rely on system prompts for any of these decisions. Instructions telling the model not to reveal confidential information don't prevent that information from being transmitted in the first place.
For basic access control, RBAC can be enough. HR employees access HR documents, finance employees access financial reports, and so on.
But in larger systems, access often depends on more than a role.
A finance employee might be authorized to access general financial reports but not documents belonging to a confidential acquisition project.
That's where ABAC becomes useful. Access can depend on department, document classification, clearance level, tenant, or project membership.
With PostgreSQL and pgvector, authorization filters can be applied directly in the retrieval query.
SELECT
id,
content,
embedding <=> :query_embedding AS distance
FROM document_chunks
WHERE tenant_id = :tenant_id
AND department = ANY(:allowed_departments)
AND classification_level <= :user_clearance
ORDER BY embedding <=> :query_embedding
LIMIT 5;
This is a simplified example. It assumes trusted authorization attributes and a defined ordering of classification levels. A real implementation may also need document-level permissions and explicit deny rules.
The important part is that filtering happens before the documents are returned.
Retrieving everything and asking the LLM to ignore unauthorized documents isn't a solution.
The same applies to agent tools. If an agent calls a tool to retrieve customer details, that tool needs its own authorization checks.
For detecting personal information, one option worth testing is Microsoft Presidio.
It's an open-source framework that supports PII detection, anonymization, and custom recognizers.
Consider this input:
Customer: Jovan Petrov
Email: jovan.petrov@klinika.mk
Phone: +389 70 121 700
Account number: 123456789
We want to detect sensitive values and replace them before sending the text to an external model, assuming company policy allows processing the remaining content.
Install Presidio:
pip install presidio-analyzer presidio-anonymizer spacy
python -m spacy download en_core_web_lg
Here's a small Python example:
from presidio_analyzer import (
AnalyzerEngine, Pattern, PatternRecognizer
)
from presidio_anonymizer import AnonymizerEngine
text = """
Customer: Jovan Petrov
Email: jovan.petrov@klinika.mk
Phone: +389 70 121 700
Account number: 123456789
"""
analyzer = AnalyzerEngine()
# Custom recognizer for account numbers
recognizer = PatternRecognizer(
supported_entity="ACCOUNT_NUMBER",
patterns=[
Pattern("account", r"\b\d{9}\b", 0.6)
],
context=["account number"]
)
analyzer.registry.add_recognizer(recognizer)
results = analyzer.analyze(
text=text,
language="en",
entities=[
"PERSON",
"EMAIL_ADDRESS",
"PHONE_NUMBER",
"ACCOUNT_NUMBER"
]
)
for entity in results:
print(entity.entity_type, entity.score)
anonymized = AnonymizerEngine().anonymize(
text=text,
analyzer_results=results
)
print(anonymized.text)
If all entities are detected correctly, the result should look similar to this:
Customer: <PERSON>
Email: <EMAIL_ADDRESS>
Phone: <PHONE_NUMBER>
Account number: <ACCOUNT_NUMBER>
The example hasn't been benchmarked. Actual detection results depend on the NLP model and recognizer configuration.
The custom account recognizer is also very basic. It matches any nine-digit number, which could produce false positives. In a real application, I would add validation based on the actual account number format.
Presidio isn't perfect either. Names can be missed, domain-specific identifiers need custom rules, and multilingual content requires additional configuration.
I would test it against a labeled dataset before depending on it in production.
And there's an important limitation: removing PII doesn't automatically make a document safe to send externally. A confidential contract is still confidential after removing names and email addresses.
Depending on what the model needs to do, we don't always have to remove sensitive information in the same way.
| Method | Example | When to use |
| ------------ | -------------- | -------------------------------------------------- |
| Redaction | [REDACTED] | Information isn't needed |
| Masking | *****6789 | Part of the value is useful |
| Tokenization | acct_tok_123 | The system needs a reference to the original value |
Tokenization is particularly useful when the model needs to work with relationships between records without seeing the original identifiers.
But reversible tokenization requires a secure mapping between tokens and original values. That mapping needs its own access controls, encryption, and auditing.
After retrieval, the application still needs to decide which model is allowed to process the data.
I would keep this logic in a centralized policy component rather than repeating it across individual agents.
A simplified implementation could look like this:
def select_route(classification, provider_approved):
if classification == "public":
return "external_llm"
if classification == "internal" and provider_approved:
return "external_llm_with_controls"
if classification in ("confidential", "restricted"):
return "approved_private_processing"
return "deny"
This isn't a complete policy engine. It only illustrates how classification can influence model routing.
A production implementation would also consider provider approval, data residency, contractual restrictions, user permissions, tenant isolation, and whether masking is sufficient.
The selected processing environment must be checked against those requirements before making the model call.
I would also avoid automatic fallback to an external provider when a private model is unavailable. A fallback shouldn't bypass the original data policy.
If classification or authorization cannot be established, the request should be denied.
I would test this layer separately from normal RAG quality evaluations.
A small evaluation dataset could contain public documents, confidential financial reports, employee information, customer identifiers, and multilingual PII examples.
I would also include requests from users who shouldn't have access to certain documents.
The metrics worth tracking are PII detection precision and recall, false-positive and false-negative rates, unauthorized retrieval rate, and policy enforcement accuracy.
False negatives are especially dangerous because sensitive information can pass through detection and reach the LLM.
Another scenario worth testing is logging.
The application might correctly remove personal information before calling the model but still store the original document in LangSmith traces or application logs.
In that case, the data is protected in one part of the pipeline but exposed in another.
Document reclassification, cross-tenant retrieval, prompt injection, and provider fallback should also be included in regression tests.
Adding these controls increases complexity and can affect latency.
Classification doesn't need to happen on every request. Most of it can be handled during ingestion. Authorization should happen during retrieval, while processing policies and data minimization are applied before the model call.
There is also a balance between removing sensitive information and keeping enough context for the model to produce a useful answer.
Aggressive masking might protect data but make the task impossible to complete. Tokenization can help in some cases, although it introduces additional infrastructure.
Using a private model is another option, but private hosting alone doesn't solve authorization, logging, or infrastructure security.
For me, the main architectural decision is to keep data access and processing permissions outside the LLM.
The model can summarize, reason over documents, and generate responses. It shouldn't decide which confidential records an employee can access or whether those records are allowed to leave the organization's infrastructure.
The most important security decision happens before the model receives the data.