PII and PCI redaction at query, context, and answer in a RAG graph
PII and PCI redaction at three points in a RAG graph: before search, before the prompt and before the UI, with golden tests for clean, card and SSN cases.
This article is published in English.
Protecting both query and response — PII & PCI redaction
RAG pipelines that index fee schedules, account docs, or support transcripts will eventually see Social Security numbers, card PANs, and names in either the user query or the retrieved context. Guardrails must cover both directions: redact sensitive spans in the inbound question, scrub retrieved chunks before they enter the prompt, and sanitize the model’s answer before it reaches the UI.
Good news
Azure AI Language (and related PII features) can detect common entity types with usable precision for a first line of defense. Combining that with allowlisted field policies in application code covers most demo and many production cases—especially when portal Foundry guardrails add a second layer.
Code changes from the previous implementation
Extend the graph with three checkpoints:
- Query guard — detect/redact PII/PCI in the user text before embedding/search
- Context guard — redact entities inside retrieved passages
- Answer guard — final pass on the generated reply
mkdir AISearchGRManualVectorization && cd AISearchGRManualVectorization && \
uv init .
uv add langgraph langchain-core langchain-openai langchain-text-splitters \
azure-ai-documentintelligence azure-core \
langchain-azure-ai azure-search-documents \
azure-ai-textanalytics \
python-dotenv
# Add all files from git repo or clone repository
@dataclass
class RedactionResult:
text: str
pii_found: bool = False
pci_found: bool = False
categories: List[str] = field(default_factory=list)
error: Optional[str] = None
def redact_text(text: str) -> RedactionResult:
if not text or not text.strip():
return RedactionResult(text=text)
client = _get_client()
results = client.recognize_pii_entities([text], language="en")
result = results[0]
if result.is_error:
return RedactionResult(text=text, error=result.error.message)
categories = [e.category for e in result.entities]
return RedactionResult(
text=result.redacted_text,
pii_found=len(categories) > 0,
pci_found=any(c in PCI_CATEGORIES for c in categories),
categories=categories,
)
def redact_query_node(state: ChatFileSearchState) -> ChatFileSearchState:
if not state.get("query"):
return state
result = guardrails.redact_text(state["query"])
if result.pii_found:
kind = "PCI" if result.pci_found else "PII"
print(f"[guardrail] {kind} detected and redacted in query")
state["query"] = result.text
return state
def redact_response_node(state: ChatFileSearchState) -> ChatFileSearchState:
if not state.get("answer"):
return state
result = guardrails.redact_text(state["answer"])
if result.pii_found:
kind = "PCI" if result.pci_found else "PII"
print(f"[guardrail] {kind} detected and redacted in response")
state["answer"] = result.text
return state
def generate_node(state: ChatFileSearchState) -> ChatFileSearchState:
if not state.get("query"):
state["answer"] = None
return state
if not state["retrieved"]:
context = "No relevant content was found in the uploaded files."
else:
context = "\n\n---\n\n".join(
f"[{doc.metadata.get('file_name', 'unknown')}] {doc.page_content}"
for doc in state["retrieved"]
)
context = guardrails.redact_text(context).text # <-- checkpoint 2
prompt = (
"Answer the question using only the context below, which comes from "
"files the user uploaded to this chat. If the answer isn't in the "
"context, say you don't know - don't make anything up.\n\n"
"Format the answer in markdown: use a short bolded summary line "
"first, then bullet points or a table for any figures, dates, or "
"line items. Keep prose minimal - prefer structure over paragraphs.\n\n"
f"Context:\n{context}\n\n"
f"Question: {state['query']}"
)
response = llm.invoke(prompt)
state["answer"] = response.content
return state
graph.add_edge("ingest", "redact_query")
graph.add_edge("redact_query", "retrieve")
graph.add_edge("retrieve", "generate")
graph.add_edge("generate", "redact_response")
graph.add_edge("redact_response", END)
┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ ┌─────┐
│ ingest ├─────►│ redact_query ├─────►│ retrieve ├─────►│ generate ├─────►│ redact_response ├─────►│ END │
└──────────┘ └──────────────┘ └──────────┘ └──────────┘ └─────────────────┘ └─────┘
uv run python -m aisearchgrmanualvectorization.main \
--session demo1 \
--query "What's the overdraft protection fee?"
Q: What's the overdraft protection fee?
A: **Overdraft protection transfer fee: $10.00 per occurrence**
- Overdraft protection transfers from a linked savings account are subject to a $10.00 fee per occurrence (as stated in the Overdraft Protection disclosure).
- If no linked savings account, insufficient funds fee: $34.00 per item (up to four items per day).
Sources:
- sample_bank_statement.pdf (chunk 7): #### Important Account Disclosures ##### Overdraft Protection If a transaction is presented that ...
- sample_bank_statement.pdf (chunk 8): ##### Interest Rate & Annual Percentage Yield This account earns interest at a variable rate, curre...
- sample_bank_statement.pdf (chunk 9): ##### Monthly Maintenance Fee A $12.00 monthly maintenance fee applies unless one of the following ...
- sample_bank_statement.pdf (chunk 3): <td>-$10.44</td> <td>$8,096.52</td> </tr> <tr> <td>02/07/2026</td> <td>Greenfield Grocers</td> <td>D...
uv run python -m aisearchgrmanualvectorization.main \
--session demo1 \
--query "My card number is 4111 1111 1111 1111, what's the late payment fee on my loan?"
[guardrail] PCI detected and redacted in query
Q: My card number is 4111 1111 1111 1111, what's the late payment fee on my loan?
A: **Short answer: Late payment fee is $35.00 or 5% of the payment, whichever is less.**
- Late Payment Fee: $35.00 or 5% of the payment amount, whichever is less
- Delinquent threshold: payment not received within 15 calendar days of its Scheduled Payment Date
- Default threshold: payment unpaid 30 calendar days past Scheduled Payment Date
Sources:
- sample_loan_agreement.pdf (chunk 7): ### 6. Late Payments and Default A payment not received within 15 calendar days of its Scheduled Pa...
- sample_loan_agreement.pdf (chunk 1): ## 2. Key Loan Terms <table> <tr> <th>Principal Amount</th> <th>$18,000.00</th> <th>Loan Term</th>...
- sample_loan_agreement.pdf (chunk 6): A complete 48-month amortization schedule is available from the Lender upon request. Figures above a...
- sample_bank_statement.pdf (chunk 9): ##### Monthly Maintenance Fee A $12.00 monthly maintenance fee applies unless one of the following ...
uv run python -m aisearchgrmanualvectorization.main \
--session demo1 \
--query "My SSN is 123-45-6789 - can you also tell me the monthly payment on the loan?"
[guardrail] PII detected and redacted in query
Q: My SSN is 123-45-6789 - can you also tell me the monthly payment on the loan?
A: **Monthly payment is $454.37**
- Monthly Payment: $454.37
Sources:
- sample_loan_agreement.pdf (chunk 1): ## 2. Key Loan Terms <table> <tr> <th>Principal Amount</th> <th>$18,000.00</th> <th>Loan Term</th>...
- sample_loan_agreement.pdf (chunk 3): "Scheduled Payment Date" means the 15th calendar day of each month during the Loan Term, or the next...
- sample_loan_agreement.pdf (chunk 6): A complete 48-month amortization schedule is available from the Lender upon request. Figures above a...
- sample_loan_agreement.pdf (chunk 4): <table> <tr> <th>Payment #</th> <th>Payment Amount</th> <th>Interest</th> <th>Principal</th> <th>Rem...
uv run python -m aisearchgrmanualvectorization.main \
--session demo1 \
--query "Who is the account holder on the bank statement?"
[guardrail] PII detected and redacted in query
Q: Who is the account holder on the bank statement?
A: **I don't know — the statement redacts that name.**
- The document shows "**************" in place of the name (redacted).
- No unredacted name or identifying information for that field appears in the provided context.
Sources:
- sample_bank_statement.pdf (chunk 0): # SAMPLE STATEMENT - SYNTHETIC TEST DATA - NOT A REAL FINANCIAL DOCUMENT # Meridian Trust Bank (fi...
- sample_bank_statement.pdf (chunk 6): <!-- PageBreak --> <table> <tr> <th>Date</th> <th>Description</th> <th>Type</th> <th>Amount</th> <...
- sample_bank_statement.pdf (chunk 10): ##### Error Resolution & Your Rights In case of errors or questions about electronic transfers, tel...
- sample_bank_statement.pdf (chunk 8): ##### Interest Rate & Annual Percentage Yield This account earns interest at a variable rate, curre..
Keep structured logs of what was redacted (entity type + offset), never the raw secret, for audit.
Execution: does it actually work
A simple query with no PII/PCI data
Expect unchanged text through all three guards and a normal retrieval answer.
Query containing fake PCI data
Plant a test PAN; confirm the query guard masks it before Search, and that logs show CreditCard-class detection without storing the digits.
Query containing fake PII (SSN)
Confirm SSN-shaped values are redacted similarly.
Pulling an account holder name out of retrieved context
Even if the user question is clean, retrieved PDF text may contain names. Context guard must mask them before the chat model sees the chunk; answer guard catches model regenerations of the name.
Operational notes
- Maintain a golden prompt pack with clean, PCI, PII, and mixed cases
- Prefer masking over deletion so sentence structure remains readable
- Never log raw PANs/SSNs “for debugging”
- Combine with Foundry portal guardrails for jailbreak/harm categories the Python layer does not own
- Review false positives on business identifiers that look like secrets (internal account codes)
Closing
PII/PCI guardrails are not optional polish on a RAG demo—they are part of the retrieval contract. Query, context, and answer each need an explicit checkpoint, tests, and logs that prove redaction happened without retaining the secret.
When integrating, fail closed on detector outages for high-risk routes (return a safe error instead of streaming unscreened context). For lower-risk internal tools, fail open only with a loud metric and a paging threshold. Document which mode each environment uses.
When integrating, fail closed on detector outages for high-risk routes (return a safe error instead of streaming unscreened context). For lower-risk internal tools, fail open only with a loud metric and a paging threshold. Document which mode each environment uses.
When integrating, fail closed on detector outages for high-risk routes (return a safe error instead of streaming unscreened context). For lower-risk internal tools, fail open only with a loud metric and a paging threshold. Document which mode each environment uses.
When integrating, fail closed on detector outages for high-risk routes (return a safe error instead of streaming unscreened context). For lower-risk internal tools, fail open only with a loud metric and a paging threshold. Document which mode each environment uses.
When integrating, fail closed on detector outages for high-risk routes (return a safe error instead of streaming unscreened context). For lower-risk internal tools, fail open only with a loud metric and a paging threshold. Document which mode each environment uses.
When integrating, fail closed on detector outages for high-risk routes (return a safe error instead of streaming unscreened context). For lower-risk internal tools, fail open only with a loud metric and a paging threshold. Document which mode each environment uses.
When integrating, fail closed on detector outages for high-risk routes (return a safe error instead of streaming unscreened context). For lower-risk internal tools, fail open only with a loud metric and a paging threshold. Document which mode each environment uses.
When integrating, fail closed on detector outages for high-risk routes (return a safe error instead of streaming unscreened context). For lower-risk internal tools, fail open only with a loud metric and a paging threshold. Document which mode each environment uses.
When integrating, fail closed on detector outages for high-risk routes (return a safe error instead of streaming unscreened context). For lower-risk internal tools, fail open only with a loud metric and a paging threshold. Document which mode each environment uses.
When integrating, fail closed on detector outages for high-risk routes (return a safe error instead of streaming unscreened context). For lower-risk internal tools, fail open only with a loud metric and a paging threshold. Document which mode each environment uses.
When integrating, fail closed on detector outages for high-risk routes (return a safe error instead of streaming unscreened context). For lower-risk internal tools, fail open only with a loud metric and a paging threshold. Document which mode each environment uses.
When integrating, fail closed on detector outages for high-risk routes (return a safe error instead of streaming unscreened context). For lower-risk internal tools, fail open only with a loud metric and a paging threshold. Document which mode each environment uses.