PII Detection Before RAG Ingestion: Screening Documents in C#
We can keep sensitive personal data out of a retrieval index by screening the exact extracted text we're about to embed and holding anything that doesn't clear the gate.
Retrieval-augmented generation (RAG) has quickly become one of the most useful patterns in the burgeoning world of enterprise AI. Rather than fine-tuning a model on private data, a RAG system instead extracts text from internal documents, splits that text into chunks, turns each chunk into a vector embedding, and stores those vectors so a language model can fluidly pull the most relevant passages at question time. The payoff from a well-designed RAG system is considerable: employees can ask plain-language questions across wikis, contracts, support tickets, and policy files and get grounded, cited answers in reply — with no training involved.
That same convenience is what makes personally identifiable information (PII) particularly dangerous here. Feeding internal documents into an AI service means copying their contents into a new, searchable, and hard-to-purge form; if any of those documents contain Social Security numbers, bank account numbers, or health details, that nifty RAG system quietly becomes a fountain of regulatory and compliance violations.
In this article, we’ll first discuss the RAG PII issue in more detail, and we’ll then focus on how to implement a C# solution that mitigates the problem. We’ll ultimately build a solution that screens the extracted text before it’s ever embedded.
A Vector Store Keeps PII Long After Ingestion
PII in a RAG pipeline is a problem of both exposure and persistence. In an ordinary request, sensitive data passes through and is gone as quickly as it came.
In ingestion, however, that data stays put. Once a document is chunked and embedded, any PII it contained lives on in the vectors, in the chunk metadata, and in every retrieval result that pulls those chunks back. That means it can resurface in a model’s answer far from the document it came from, in front of a user who was never meant to see it. By the time that mistake is discovered, mitigation means finding and removing every derived copy across the entire index, which is, of course, far harder than never ingesting that data in the first place. Screening at the point of ingestion is the last inexpensive moment we have available to keep regulated data out.
Detection Is a Decision Point, Not a Redaction Step
Before we build a solution for RAG PII mitigation, it helps to separate the two things we might do with sensitive text once we've identified it. We can detect it and decide whether the document can proceed in the pipeline, or we can redact it and, in doing so, alter the document before it moves beyond our workflow.
This article is ultimately focused on implementing the first concept: a PII gate. The policy for that is simple. A document the detector clears for RAG use is eligible for the index; a document that trips it is held, and we distinguish a hard-block category (such as a Social Security or bank account number) from one that merely warrants a human look.
Screen the Exact Text You Embed
The embedding process requires text — and that, of course, means a RAG pipeline always produces extracted text. That text is what we want to screen; it’s the exact artifact about to be chunked and embedded. Screening a source image or file and then extracting text separately means checking one representation while ingesting another, and that’s problematic because the two can diverge. Hidden layers, OCR differences, and metadata can all carry data that the version you scanned never showed. So we avoid that problem by screening after extraction and before chunking, which makes the bytes we approve the same bytes we embed.
What We Can Build With Open-Source Components
A good chunk of the tooling we need already lives in our ecosystem. .NET gives us all the surrounding machinery we need for free: file handling, size limits, streaming, and so on. What it does not give us, however, is any tool specifically optimized for reliable PII detection.
Regular expressions come to mind, and they can certainly catch well-formed Social Security and credit card numbers, but they miss names, addresses, and the many identifiers that appear in free-form, context-dependent ways, and they’re prone to false alarms on ordinary numbers. Health information is harder still, since recognizing references to medical conditions or prescribed treatments requires a level of contextual understanding that simple pattern matching cannot independently make up for. A managed detector, one example of which we’ll use in our solution below, absorbs that recognition problem. The piece of the puzzle it leaves to us is the one that is indisputably ours: choosing which categories to permit, setting the policies to block unpermitted categories, and testing those against documents that closely resemble our real document corpus.
Adding a PII Detection Step in C#
In our demonstration, we’ll use a DLP API optimized for detecting configurable categories of PII and PHI in text, and we’ll treat its verdict as the screening result that drives the RAG PII gate.
Please note a strong assumption baked in here: we’re using an endpoint deployed on our own infrastructure. That goes for any comparable service. If we don’t account for this, we’re just doubling our data loss exposure. We should not use publicly hosted web services to screen sensitive data.
To set up, we’ll first create a console project and install the SDK:
dotnet new console --framework net8.0 -n RagPiiGate
cd RagPiiGate
dotnet add package Cloudmersive.APIClient.NETCore.DLP --version 1.1.0
using System;
using System.IO;
using Cloudmersive.APIClient.NETCore.DLP.Api;
using Cloudmersive.APIClient.NETCore.DLP.Client;
using Cloudmersive.APIClient.NETCore.DLP.Model;
Building the PII Screener Step by Step
We’ll now build the program in the order the data flows. The chunks below concatenate into one to-level Program.cs; we’ve broken it down this way for extra clarity.
First, we’ll gather what the operator controls and reject anything missing. The API key and the two destination directories come from the environment, never from the document.
try
{
if (args.Length != 2)
throw new ArgumentException("Usage: RagPiiGate <extracted-text-path> <document-id>");
string Required(string name) =>
Environment.GetEnvironmentVariable(name) is { Length: > 0 } value
? value : throw new InvalidOperationException($"Set {name}.");
string key = Required("CLOUDMERSIVE_API_KEY");
string acceptDir = Required("ACCEPT_DIR");
string quarantineDir = Required("QUARANTINE_DIR");
// Pipeline-supplied, never derived from document content.
string documentId = Path.GetFileName(args[1]);
if (string.IsNullOrWhiteSpace(documentId) || documentId != args[1])
throw new InvalidOperationException("Document id must be a simple file name.");
With configuration validated, nothing downstream depends on an untrusted value. We’ll assume the input file is the finished product of a prior extraction stage, so we’ll only read it, bound its size, and confirm it’s not empty (empty text would otherwise pass as clean).
const int maxChars = 1 * 1024 * 1024;
var info = new FileInfo(args[0]);
if (!info.Exists)
throw new InvalidOperationException("Extracted-text file was not found.");
if (info.Length > maxChars)
throw new InvalidOperationException("Extracted text exceeds the screening size limit.");
string text = File.ReadAllText(args[0]);
if (string.IsNullOrWhiteSpace(text))
throw new InvalidOperationException("Extracted text is empty; nothing to screen.");
At this point, we finally have meaningful text to screen — so we’ll send it off and keep the verdict. The Allow flags are our policy: we permit names and addresses, common in an internal knowledge base, and refuse the high-risk identifiers, financial data, health PHI, and secrets.
bool accepted = verdict.CleanResult == true;
bool hardBlock =
verdict.ContainsSocialSecurityNumber == true ||
verdict.ContainsCreditCardNumber == true ||
verdict.ContainsBankAccountNumber == true ||
verdict.ContainsPassportNumber == true ||
verdict.ContainsPrivateKeys == true ||
verdict.ContainsCredentials == true ||
verdict.ContainsHealthInsuranceNumber == true;
string decision = accepted ? "accept"
: hardBlock ? "quarantine (blocked category)"
: "quarantine (review)";
Detection told us what is in the document; policy now decides what that actually means. In this case, CleanResult is the gate, and the Contains flags let us classify why a document was held.
var configuration = new Configuration();
configuration.AddApiKey("Apikey", key);
var detector = new DetectApi(configuration);
var request = new DlpAdvancedDetectionRequest
{
InputText = text,
AllowPersonName = true,
AllowStreetAddress = true,
AllowEmailAddress = false,
AllowPhoneNumber = false,
AllowBirthDate = false,
AllowPassportNumber = false,
AllowDriversLicense = false,
AllowSocialSecurityNumber = false,
AllowTaxpayerID = false,
AllowCreditCardNumber = false,
AllowBankAccountNumber = false,
AllowIBAN = false,
AllowHealthInsuranceNumber = false,
AllowHealthInsuranceMemberID = false,
AllowHealthInjuryOrDisease = false,
AllowHealthTypeOfTreatment = false,
AllowCredentials = false,
AllowPrivateKeys = false,
AllowBearerToken = false,
ProvideAnalysisRationale = true
};
DlpAdvancedDetectionResponse verdict = detector.DetectTextAdvanced(request);
if (verdict is null)
throw new InvalidOperationException("Detection returned no result.");
A quick note here: because we set ProvideAnalysisRationale, the response’s AnalysisRationale explains in plain language why a document was flagged, which is intended to help a reviewer triage the quarantine path. It earns that usefulness by quoting the sensitive data back, so we’ll just treat it like the PII it describes: never logged, and surfaced only in an access-controlled quarantine store.
For our final step, we’ll route the file to an operator-chosen directory. Clean text goes to the “accept” directory for the rest of the pipeline; everything else goes to quarantine and stays out of the index. FileMode.CreateNew refuses to overwrite, so a repeated ID surfaces as an error.
string targetDir = accepted ? acceptDir : quarantineDir;
Directory.CreateDirectory(targetDir);
string targetPath = Path.Combine(targetDir, documentId + ".txt");
using (var output = new FileStream(targetPath, FileMode.CreateNew,
FileAccess.Write, FileShare.None))
using (var writer = new StreamWriter(output))
{
writer.Write(text);
}
Console.WriteLine($"{documentId}: {decision}.");
}
In this example, one catch covers the whole pipeline, and it never echoes the text, the detected values, or the rationale, any of which can carry the very PII we’re trying to keep contained.
catch (Exception error)
{
Console.Error.WriteLine($"Screening stopped ({error.GetType().Name}).");
Environment.ExitCode = 1;
}
Because the only path to the “accept” directory is an explicit clean verdict, every error and every ambiguous result resolves the same safe way: not ingested, never ingested anyway.
Conclusion
PII becomes a serious, durable liability the moment it enters a retrieval index, so the time to check for it is at ingestion, before any text is chunked and embedded. We saw that the right place to screen is the exact extracted text we are about to embed, and that detection is a decision point rather than a cleanup step. We learned that a PII gate should always fail safe: only an explicit clean verdict should reach the index, and every error or ambiguous result should be held.
With a clear category policy and a very disciplined handling of flagged content, a RAG pipeline can stay useful without turning an internal knowledge base into a channel for exposing regulated data.
Opinions expressed by DZone contributors are their own.