OGuardAI
GuidesCase Studies

RAG Document Pipeline

How a company safely ingests internal documents into a RAG system so the vector store and LLM never see employee or customer PII

How a company safely ingests internal documents into a RAG system so the vector store and LLM never see employee or customer PII.


Note: This scenario relies on detecting person and company names. Name, company, and location detection requires the Python NER sidecar (GUARDAI_DETECTOR_URL, with detector.mode: both or advanced); in builtin-only mode those are not detected. Set detection.required_for: [person, company] in the policy to fail closed if the sidecar is down. See Detector Capabilities.

The Situation

A 2,000-person technology company wants to build an internal knowledge assistant. The knowledge base includes HR policies, employee handbooks, customer contracts, support ticket archives, and internal memos. These documents are full of names, email addresses, phone numbers, employee IDs, customer account numbers, and salary figures.

The engineering team builds a standard RAG pipeline: chunk documents, embed them, store vectors, retrieve relevant chunks at query time, and pass them to an LLM for answer generation. The security review blocks deployment. The vector store (hosted on a managed service) would contain raw PII in the chunk text. The LLM provider would receive PII in retrieved context. Every query could leak sensitive data.


The Solution

OGuardAI is inserted at two points in the RAG pipeline: document ingestion (tokenize before embedding) and query time (transform the question, then rehydrate the answer). The vector store only ever contains tokenized text. The LLM only ever sees tokenized context.


The 4-Step RAG Flow

The vector store and the LLM only ever see tokens; raw values are restored only at the final answer, for the user. A shared corpus_id keeps token identity consistent across ingest, query, and context.

Step 1: Document Ingestion (Tokenize Before Embedding)

A document from the support archive is being ingested:

From: Sarah Chen <sarah.chen@acmecorp.com>
To: Support Team
Subject: Account #AC-2026-8834 - Billing dispute

Hi team,

Customer James Rodriguez (james.r@outlook.com, phone 415-555-0192)
called about a double charge of $847.50 on invoice INV-44021.
His employee contact at our company is Mike Thompson (ext. 4421).
Please resolve within 24 hours per our SLA.

Sarah Chen
Customer Success Manager

The ingestion service sends the document to OGuardAI:

curl -X POST http://localhost:3000/v1/transform \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $GUARDAI_API_KEY" \
  -d '{
    "input": "From: Sarah Chen <sarah.chen@acmecorp.com>\nTo: Support Team\nSubject: Account #AC-2026-8834 - Billing dispute\n\nHi team,\n\nCustomer James Rodriguez (james.r@outlook.com, phone 415-555-0192) called about a double charge of $847.50 on invoice INV-44021. His employee contact at our company is Mike Thompson (ext. 4421). Please resolve within 24 hours per our SLA.\n\nSarah Chen\nCustomer Success Manager",
    "policy": "rag-ingest"
  }'

Detected entities:

Original ValueEntity TypeToken
Sarah Chenperson{{person:p_001:ad4f97591c16}}
sarah.chen@acmecorp.comemail{{email:e_001:1bcaef1a4aff}}
AC-2026-8834customer_id{{customer_id:cid_001:d00188a0c40c}}
James Rodriguezperson{{person:p_002:98e1f012a96f}}
james.r@outlook.comemail{{email:e_002:3bd0ea511c7d}}
415-555-0192phone{{phone:ph_001:71453119be53}}
Mike Thompsonperson{{person:p_003:e19cd15f15fb}}

The tokenized text stored in the vector database:

From: `{{person:p_001:ad4f97591c16}}` <`{{email:e_001:1bcaef1a4aff}}`>
To: Support Team
Subject: Account #`{{customer_id:cid_001:d00188a0c40c}}` - Billing dispute

Hi team,

Customer `{{person:p_002:98e1f012a96f}}` (`{{email:e_002:3bd0ea511c7d}}`, phone `{{phone:ph_001:71453119be53}}`)
called about a double charge of `{{custom:money:x_001:601595ba1e2c}}` on invoice INV-44021.
His employee contact at our company is `{{person:p_003:e19cd15f15fb}}` (ext. 4421).
Please resolve within 24 hours per our SLA.

`{{person:p_001:ad4f97591c16}}`
Customer Success Manager

The session state blob is stored alongside the document chunk metadata in the ingestion database (not in the vector store).

Step 2: Query Transformation

An employee asks the knowledge assistant: "What happened with the billing dispute from James Rodriguez?"

The query is transformed before retrieval:

curl -X POST http://localhost:3000/v1/transform \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $GUARDAI_API_KEY" \
  -d '{
    "input": "What happened with the billing dispute from James Rodriguez?",
    "policy": "rag-query"
  }'

Result:

What happened with the billing dispute from `{{person:p_001:ad4f97591c16}}`?

The vector search uses the tokenized query. Because the ingested chunks also contain {{person:...}} tokens, semantic similarity still works: the embedding model captures the structural pattern around "billing dispute" and the person token.

Aligning tokens across documents and sessions with corpus_id. The simplified /v1/transform calls above assign token ids per request, so the query's "James Rodriguez" gets {{person:p_001:ad4f97591c16}} here while ingestion assigned {{person:p_002:98e1f012a96f}}. Different ids do not hurt vector similarity, but they do not let you join a query entity to a specific ingested entity. To make the same raw value tokenize to the same id across every document and query, the RAG endpoints (/v1/rag/ingest, /v1/rag/query, /v1/rag/context) accept an optional corpus_id. Pass the same corpus_id on ingest and query and "James Rodriguez" resolves to one deterministic corpus-scoped token everywhere, so the query token lines up with the ingested document token. corpus_id is corpus-scoped, not a global fingerprint; leaving it unset keeps the random per-token scheme, and /v1/rag/context rejects a mismatched corpus_id rather than restoring across identity domains.

Step 3: Context Assembly

The retrieval system finds the matching chunk (from Step 1). The tokenized chunk is passed directly to the LLM as context: it is already safe. No additional transformation is needed for the retrieved context because it was tokenized at ingestion time.

In a production pipeline that retrieves across many documents through /v1/rag/ingest and /v1/rag/context, ingest returns a per-chunk chunk_session_state (a sealed blob holding only that chunk's tokens). Store it next to the chunk in the vector database. At context time, pass the retrieved chunks plus their chunk_session_state values as the document_sessions array to /v1/rag/context. The runtime merges each admitted chunk's document tokens into the session so a document-only entity resolves in the answer, fail closed: a chunk filtered by access level is never merged, and a token id that two documents disagree on is rejected rather than restored to a wrong value.

Step 4: Answer Rehydration

The LLM generates an answer using the tokenized context:

Based on the support ticket, `{{person:p_002:98e1f012a96f}}` reported a double charge
of $847.50 on invoice INV-44021 for account `{{customer_id:cid_001:d00188a0c40c}}`.
The ticket was assigned to `{{person:p_003:e19cd15f15fb}}` for resolution within
24 hours per SLA. The original report was filed by `{{person:p_001:ad4f97591c16}}`
from the Customer Success team.

The answer is rehydrated for the requesting employee:

curl -X POST http://localhost:3000/v1/rehydrate \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $GUARDAI_API_KEY" \
  -d '{
    "output": "<LLM answer with tokens>",
    "session_state": "<encrypted-blob-from-ingest>",
    "output_channel": "internal_summary"
  }'

The employee sees the fully restored answer:

Based on the support ticket, James Rodriguez reported a double charge
of $847.50 on invoice INV-44021 for account AC-2026-8834. The ticket
was assigned to Mike Thompson for resolution within 24 hours per SLA.
The original report was filed by Sarah Chen from the Customer Success team.

Policy Configuration

name: rag-ingest
version: "1.0"
rules:
  - entity_type: person
    protection_level: 2
    action: tokenize
    restore_mode: full
  - entity_type: email
    protection_level: 2
    action: tokenize
    restore_mode: full
  - entity_type: phone
    protection_level: 2
    action: tokenize
    restore_mode: masked
  - entity_type: customer_id
    protection_level: 2
    action: tokenize
    restore_mode: full
  - entity_type: ssn
    protection_level: 1
    action: redact

defaults:
  protection_level: 2
  action: tokenize
  restore_mode: full

metadata_policy:
  expose_gender: true
  expose_formality: true
  expose_language: true
  expose_role: true

channel_rules:
  internal_summary:
    person: full
    email: full
    phone: masked
    customer_id: full
  customer_email:
    person: partial
    email: none
    phone: none
    customer_id: masked

Save the policy under policies/rag-ingest/policy.yaml (and the matching rag-query policy referenced in Step 2 under policies/rag-query/), then start the server with the policy directory so both policy names resolve at request time.


What OGuardAI Made Possible

Safe vector storage. The managed vector store service contains zero PII. If the vector store is breached, attackers find only semantic tokens that cannot be reversed without the encrypted session blobs stored separately in the company's infrastructure.

End-to-end protection. PII is tokenized once at ingestion and stays tokenized through retrieval, context assembly, and LLM processing. Real values are restored only at the final step, only for authorized users, only according to policy.

Semantic search still works. Tokenized text preserves document structure and surrounding context. The embedding model captures the meaning around tokens ("billing dispute," "double charge," "SLA") so retrieval quality is maintained.

Blocked sensitive categories. SSN values in HR documents are blocked entirely at ingestion: they never enter the vector store, not even as tokens. The policy enforces this for the entire document corpus.