SHADOW AI

A modern AI system cannot reliably answer questions about private documents merely by sending the entire document to a language model. A scalable architecture requires a dedicated document intelligence pipeline that converts uploaded files into structured, searchable, permission-aware knowledge.
The complete pipeline is:
Upload → Validate → Store → Extract → Normalize → Segment → Embed → Index → Retrieve → Rerank → Generate → Cite → Audit
This chapter defines the foundation for that pipeline.
The objective is not simply to make documents searchable. The objective is to create a retrieval system that is:
accurate,
scalable,
permission-aware,
traceable,
versioned,
privacy-preserving,
resistant to untrusted document instructions,
and suitable for future AI-agent workflows.
Documents are heterogeneous.
A PDF may contain:
selectable text,
scanned images,
tables,
headings,
footnotes,
page numbers,
metadata,
diagrams.
A DOCX file may contain:
paragraphs,
headings,
tables,
lists,
hyperlinks,
embedded media.
An HTML document may contain:
visible text,
navigation,
scripts,
advertisements,
metadata,
hidden elements.
Therefore, the application should not treat every uploaded file as a simple text string.
Instead, the system should convert each document into a normalized internal representation.
A useful conceptual model is:
Original File
│
▼
File Metadata
│
▼
Extraction
│
▼
Normalized Document
│
▼
Logical Sections
│
▼
Retrieval Chunks
│
▼
Embeddings
│
▼
Vector Index
Each stage should be independently testable.
A document should have an explicit processing state.
Recommended states include:
UPLOADED
VALIDATING
QUEUED
EXTRACTING
NORMALIZING
CHUNKING
EMBEDDING
INDEXING
READY
FAILED
DELETED
This prevents the application from assuming that an uploaded document is immediately searchable.
For example:
User uploads report.pdf
↓
UPLOADED
↓
VALIDATING
↓
EXTRACTING
↓
NORMALIZING
↓
CHUNKING
↓
EMBEDDING
↓
INDEXING
↓
READY
If processing fails, the document should move to FAILED with a safe diagnostic record.
The first rule of document ingestion is:
Never trust the filename or declared MIME type alone.
A file called:
report.pdf
does not prove that the underlying bytes actually represent a valid PDF.
Validation should consider:
File size.
Filename.
Extension.
Declared MIME type.
Detected content type.
File signature where appropriate.
Parser compatibility.
Security scanning.
User/project authorization.
Storage quota.
A safe validation pipeline is:
Upload
↓
Size Check
↓
Content-Type Check
↓
Content Signature Check
↓
Security Scan
↓
Parser Selection
↓
Extraction
Rejected files should not enter the normal AI processing pipeline.
The initial implementation can support a controlled set of formats.
TXT
Markdown
DOCX
HTML
CSV
JSON
XML
Additional specialized formats can be added through isolated parser modules.
The architecture should not place format-specific logic directly inside the API route.
Instead:
Document API
│
▼
Ingestion Service
│
├── PDF Extractor
├── DOCX Extractor
├── TXT Extractor
├── HTML Extractor
└── CSV Extractor
This makes future expansion easier.
The extraction layer converts binary documents into structured text.
A simple internal representation could be:
ExtractedDocument
├── documentId
├── title
├── pages
│ ├── pageNumber
│ ├── text
│ └── metadata
├── sections
└── metadata
For a PDF, preserving page boundaries is particularly useful because later citations can point to the relevant page.
Instead of storing only:
"The organization established..."
the system should preserve:
Document: annual-report.pdf
Page: 37
Section: Risk Management
Text: "The organization established..."
This greatly improves traceability.
Some PDFs contain images rather than selectable text.
The extraction system should detect this condition.
Conceptually:
PDF
│
├── Text Layer Present → Text Extraction
│
└── No Useful Text → OCR Pipeline
OCR output should be treated as extracted data rather than authoritative truth.
OCR may introduce:
character errors,
missing punctuation,
incorrect tables,
incorrect line breaks,
incorrect reading order.
Therefore, the document metadata should record whether text originated from:
native extraction
or:
OCR
This information can later be used for quality assessment.
Raw extraction frequently contains unnecessary formatting artifacts.
Examples include:
Multiple spaces
Broken
line breaks
Page headers
Page footers
Normalization may include:
whitespace normalization,
line-break normalization,
Unicode normalization,
removal of repeated extraction artifacts,
preservation of meaningful headings,
preservation of page boundaries,
preservation of paragraph boundaries.
Normalization must not destroy information required for citations.
For example, removing all page boundaries would make later source attribution unnecessarily difficult.
Every processed document should maintain metadata.
Recommended fields include:
documentId
fileId
projectId
ownerId
title
mimeType
fileSize
language
pageCount
processingStatus
processingVersion
extractionMethod
createdAt
updatedAt
Additional metadata may include:
sourceName
checksum
documentVersion
parserVersion
ocrUsed
embeddingModel
embeddingVersion
Metadata should be considered part of the document's security boundary.
A malicious or untrusted document should not be able to redefine authorization metadata.
Documents change.
A user may upload:
policy-v1.pdf
and later replace it with:
policy-v2.pdf
The application should decide whether these represent:
separate documents, or
versions of the same logical document.
For enterprise-style systems, versioning is often preferable.
Conceptually:
Logical Document
│
├── Version 1
├── Version 2
└── Version 3
Only the active version should normally participate in default retrieval.
Older versions can remain available for historical queries if explicitly requested.
Large documents must be divided into smaller retrieval units called chunks.
A chunk is not simply an arbitrary substring.
A good chunk should ideally preserve a coherent piece of meaning.
For example:
Chapter
↓
Section
↓
Paragraph Group
↓
Chunk
The goal is to make each retrieved unit:
understandable,
sufficiently self-contained,
small enough for efficient retrieval,
large enough to preserve context.
The simplest approach is fixed-size chunking.
For example:
Chunk size = approximately N tokens
Overlap = approximately M tokens
Conceptually:
Document:
[A B C D E F G H I J]
Chunk 1:
[A B C D]
Chunk 2:
[D E F G]
Chunk 3:
[G H I J]
Overlap helps prevent important information from being split exactly at a boundary.
However, fixed-size chunking can separate:
headings from paragraphs,
questions from answers,
table labels from values,
definitions from explanations.
Therefore, fixed-size chunking is useful as a baseline, not necessarily the final strategy.
A stronger strategy respects document structure.
For example:
Section 4
│
├── Paragraph 1
├── Paragraph 2
└── Paragraph 3
can become:
Chunk:
Section 4
Paragraph 1
Paragraph 2
Paragraph 3
This gives the embedding model more meaningful context.
The chunk metadata should preserve:
documentId
chunkIndex
sectionPath
pageNumber
startOffset
endOffset
tokenCount
Semantic chunking attempts to identify natural topic boundaries.
For example:
Introduction
↓
Methodology
↓
Results
↓
Discussion
↓
Conclusion
Each topic can be processed as a coherent retrieval unit.
Semantic chunking may improve retrieval quality, but it also increases implementation complexity.
Therefore, a practical development path is:
Version 1 → structure-aware chunking
Version 2 → improved semantic segmentation
Version 3 → adaptive chunking based on document type
The chunk content alone is not enough.
Each chunk should retain enough information to reconstruct its origin.
Example:
{
"documentId": "doc_123",
"chunkIndex": 18,
"pageNumber": 7,
"sectionPath": ["Security", "Access Control"],
"tokenCount": 420
}
This allows the system to answer:
Where did this information come from?
That question is essential for trustworthy AI.
An embedding converts text into a numerical vector representing semantic characteristics of that text.
Conceptually:
Text
↓
Embedding Model
↓
[0.021, -0.183, 0.442, ...]
The exact numerical representation depends on the selected embedding model.
The architecture should therefore use an abstraction:
EmbeddingProvider
rather than hard-coding a particular provider.
Conceptually:
EmbeddingService
│
├── Provider A
├── Provider B
└── Local Provider
This allows the system to change models later without rewriting the entire retrieval layer.
Embedding models can change.
If the system originally indexed documents using:
embedding-model-v1
and later moves to:
embedding-model-v2
the existing vectors may no longer be directly comparable.
Therefore, store:
embeddingModel
embeddingVersion
embeddingDimensions
with the vector index metadata.
A model migration can then follow:
Existing Chunks
↓
Generate New Embeddings
↓
Build New Index
↓
Evaluate
↓
Switch Active Index
This prevents uncontrolled index migrations.
A vector store maps:
chunk → embedding
A conceptual record looks like:
VectorRecord
├── id
├── chunkId
├── embedding
├── model
├── dimensions
└── metadata
The system can use:
a PostgreSQL vector extension,
a dedicated vector database,
or another vector-capable storage layer.
The application should hide this implementation behind a repository interface.
For example:
VectorRepository
├── insert()
├── delete()
├── search()
└── replaceIndex()
The rest of the application should not need to know which vector engine is being used.
When a user asks a question, the query can also be embedded.
Conceptually:
User Question
↓
Query Embedding
↓
Vector Search
↓
Candidate Chunks
The system then ranks chunks according to semantic similarity.
However, semantic similarity alone is not sufficient.
A highly similar chunk may belong to another project or another user.
Therefore:
Authorization filtering must occur as part of retrieval, not after retrieval.
Suppose the database contains:
Project A
Project B
Project C
The current user only has access to Project B.
A vector search must not retrieve:
Project A chunk
Project C chunk
and then attempt to remove them later.
The preferred model is:
User
↓
Authorized Projects
↓
Retrieval Filter
↓
Vector Search
↓
Allowed Candidates
This is a fundamental multi-tenant security principle.
Vector similarity is useful, but lexical search can be better for:
exact names,
identifiers,
product codes,
legal clauses,
dates,
unusual terminology.
A robust system can combine:
Vector Search
+
Keyword Search
↓
Candidate Set
↓
Reranking
This is commonly called hybrid retrieval.
The exact weighting should be configurable and evaluated empirically.
The initial retrieval stage may return:
Top 20 candidates
A reranking stage can evaluate those candidates more precisely and select:
Top 5 candidates
Conceptually:
Query
↓
Candidate Retrieval
↓
Reranker
↓
Best Evidence
This can improve answer quality without requiring the expensive ranking model to process the entire document collection.
The final RAG architecture is:
User Question
│
▼
Authorization
│
▼
Query Processing
│
▼
Retrieval
│
▼
Reranking
│
▼
Context Selection
│
▼
Language Model
│
▼
Answer + Citations
The model should not be presented with arbitrary unrelated data.
The application should construct a controlled context window.
Suppose retrieval produces five relevant chunks.
The system may assemble:
SOURCE 1
Document: Security Policy
Page: 12
Section: Authentication
[retrieved content]
SOURCE 2
Document: Security Policy
Page: 14
Section: Sessions
[retrieved content]
The model then receives these as evidence.
The context builder should enforce:
maximum number of chunks,
token budget,
duplicate removal,
relevance threshold,
source diversity where appropriate,
authorization constraints.
A trustworthy RAG system should retain a relationship between:
Answer
↓
Retrieved Chunk
↓
Document
↓
File
↓
Original Storage Object
This enables users to inspect the source.
For example:
Answer statement
↓
Document: Security Policy
↓
Page: 14
↓
Section: Session Management
This is significantly more useful than simply saying:
"According to the documents..."
Retrieval improves grounding, but it does not guarantee correctness.
Potential failures include:
incorrect extraction,
poor chunk boundaries,
irrelevant retrieval,
outdated documents,
contradictory documents,
model misunderstanding,
unsupported inference.
Therefore, the system should distinguish between:
Retrieved evidence
and:
Generated interpretation
Evaluation should measure both retrieval quality and answer quality.
This is one of the most important security requirements.
A document may contain text such as:
Ignore previous instructions...
or other content that attempts to influence an AI system.
The retrieval system must treat that content as data, not as privileged instructions.
The architectural rule is:
System Policy
↓
Developer Policy
↓
User Request
↓
Retrieved Documents = Untrusted Evidence
Retrieved content must never automatically gain permission to:
change system policies,
modify access controls,
reveal secrets,
execute tools,
change agent permissions,
override security rules.
This separation is especially important when RAG is later connected to autonomous agents.
A document can be intentionally or accidentally written in a way that attempts to manipulate the model.
Therefore, the RAG layer should establish a strong conceptual boundary:
INSTRUCTIONS
versus:
EVIDENCE
The model may use evidence to answer the question, but evidence should not become a new authority layer.
For example:
Question:
"What does the policy say about password rotation?"
Retrieved document:
"...passwords must be changed every 90 days..."
The model should extract the relevant policy statement.
If the document also contains unrelated text attempting to control the AI, that text should remain untrusted document content.
The system may eventually support both:
Long-Term Memory
and:
Document Knowledge
They should not be treated as identical.
Documents are external evidence.
Memory represents retained application context.
A useful architecture is:
User Question
│
├── Document Retrieval
│
├── Memory Retrieval
│
└── Conversation Context
│
▼
Context Manager
│
▼
Model Response
Each source should retain provenance.
Document ingestion should normally run asynchronously.
The upload request should not remain open while a large document is:
parsed,
OCR processed,
chunked,
embedded,
indexed.
Instead:
Upload API
↓
Create Processing Job
↓
Return Job ID
↓
Worker Processes Document
↓
Update Status
The user interface can then display:
Processing document...
followed by:
Document ready
or:
Document processing failed
The same processing job should not accidentally create duplicate chunks or duplicate vectors.
A useful strategy is to calculate a content checksum.
Conceptually:
File Bytes
↓
Checksum
↓
Document Version
If the same content is uploaded again, the system can determine whether processing can be reused.
Processing should also use unique constraints such as:
(documentId, chunkIndex)
to prevent accidental duplicate chunks.
A document may need to be reprocessed when:
the extraction engine changes,
the chunking strategy changes,
the embedding model changes,
OCR quality improves,
a bug is fixed.
The architecture should support:
Reprocess Document
without manually reconstructing database records.
A reprocessing job can:
Load Original File
↓
Extract Again
↓
Create New Document Version
↓
Create New Chunks
↓
Generate New Embeddings
↓
Build New Index Records
↓
Activate New Version
Deleting a document must remove its associated retrieval data.
Conceptually:
Delete Document
│
├── Delete Chunks
├── Delete Embeddings
├── Remove Search Index Entries
├── Update Storage Metadata
└── Record Audit Event
A document must not remain retrievable after its authorization has been revoked.
This is especially important for privacy and multi-tenant systems.
The database foundation introduced earlier can now be extended with document-specific structures.
Conceptually:
Document
├── id
├── fileId
├── projectId
├── ownerId
├── title
├── mimeType
├── status
├── version
├── extractionMethod
├── metadata
├── createdAt
└── updatedAt
And:
DocumentChunk
├── id
├── documentId
├── chunkIndex
├── content
├── pageNumber
├── sectionPath
├── startOffset
├── endOffset
├── tokenCount
├── metadata
└── createdAt
Vector records can be stored through the selected vector-storage abstraction.
The application should expose interfaces similar to:
DocumentRepository
DocumentChunkRepository
EmbeddingRepository
VectorRepository
RetrievalRepository
Example responsibilities:
DocumentRepository
create()
findById()
updateStatus()
delete()
createVersion()
DocumentChunkRepository
createMany()
findByDocument()
deleteByDocument()
VectorRepository
upsert()
search()
deleteByChunk()
This maintains the separation between business logic and storage implementation.
The chunking system should be independent from the extraction service.
Architecture:
ExtractionService
↓
NormalizedDocument
↓
ChunkingService
↓
DocumentChunk[]
This makes it possible to test multiple chunking strategies against the same extracted document.
The embedding service should expose a simple interface.
Conceptually:
embed(text)
embedBatch(text[])
The implementation can then select the configured embedding provider.
This avoids coupling application code to a particular AI vendor or model.
The retrieval service combines:
Authorization
Query Embedding
Vector Search
Keyword Search
Reranking
Context Selection
A conceptual interface:
retrieve({
userId,
projectId,
query,
limit
})
The result should include provenance:
RetrievalResult
├── chunkId
├── documentId
├── score
├── content
├── pageNumber
└── sourceMetadata
A retrieval system must be measured rather than assumed to work.
Important retrieval metrics include:
How often the relevant evidence appears within the top K results.
How many retrieved results are actually relevant.
Mean Reciprocal Rank measures how highly the first relevant result appears.
Useful when multiple results have different relevance levels.
For generation, additional evaluation can examine:
factual grounding,
citation correctness,
unsupported claims,
answer completeness,
refusal behavior,
latency,
token usage.
A serious implementation should maintain a small evaluation dataset.
Example:
Question
Expected Document
Expected Section
Expected Page
Relevant Chunk
Expected Answer
For example:
Question:
"What is the session timeout?"
Expected source:
Security Policy
Expected section:
Session Management
Expected page:
14
This allows retrieval changes to be evaluated objectively.
The ingestion system should be tested against:
malformed files,
oversized files,
unsupported formats,
corrupted PDFs,
OCR errors,
duplicate uploads,
unauthorized project access,
deleted documents,
cross-project retrieval,
malicious document instructions,
metadata manipulation,
stale embeddings.
The goal is not simply:
Does the system retrieve information?
The stronger question is:
Does the system retrieve only information the current user is authorized to access?
Every ingestion job should have a traceable identifier.
For example:
ingestionJobId
documentId
fileId
projectId
Logs can then connect:
Upload
↓
Extraction
↓
Chunking
↓
Embedding
↓
Indexing
Useful measurements include:
extraction_duration
chunk_count
embedding_count
index_duration
failure_count
retrieval_latency
retrieval_result_count
Sensitive document content should not be unnecessarily written into logs.
Possible failures include:
ExtractionFailed
OCRFailed
ChunkingFailed
EmbeddingFailed
IndexingFailed
AuthorizationFailed
StorageUnavailable
The system should distinguish between:
temporary failure
and:
permanent failure
Temporary failures can be retried.
Permanent failures should be surfaced safely to the user.
The complete architecture now becomes:
USER
│
▼
Upload API
│
▼
Authorization
│
▼
File Validation
│
▼
Object Storage
│
▼
Ingestion Queue
│
▼
Processing Worker
│
┌──────────┴──────────┐
▼ ▼
Text Extraction OCR
│ │
└──────────┬──────────┘
▼
Normalized Text
│
▼
Chunking
│
▼
Document Chunks
│
▼
Embeddings
│
▼
Vector Storage
│
▼
Retrieval Layer
│
┌───────┴────────┐
▼ ▼
Vector Search Keyword Search
│ │
└───────┬────────┘
▼
Rerank
│
▼
Context Builder
│
▼
AI Model
│
▼
Answer + Citations
│
▼
Audit
The following principles should guide the implementation:
The original file, extracted text, chunks, and vectors have different responsibilities.
Every chunk must know where it came from.
Do not retrieve unauthorized information and filter it afterward.
Retrieved content must not become a privileged instruction source.
Large documents should be handled by workers rather than blocking API requests.
Extraction, chunking, and embedding versions should be traceable.
Deleting a document must propagate through storage, database, and retrieval indexes.
RAG quality should be evaluated using a repeatable dataset rather than subjective testing alone.
This chapter established the foundation for document intelligence and Retrieval-Augmented Generation.
The system now has a complete conceptual lifecycle:
Upload → Validate → Store → Extract → Normalize → Chunk → Embed → Index → Retrieve → Rerank → Generate → Cite → Audit
The architecture separates:
Original Files
↓
Document Metadata
↓
Extracted Content
↓
Chunks
↓
Embeddings
↓
Vector Index
↓
Retrieved Evidence
↓
AI Response
Most importantly, the architecture introduces three critical boundaries:
Storage boundary — large files remain in object storage.
Authorization boundary — retrieval is constrained by user/project permissions.
Instruction boundary — retrieved documents remain untrusted evidence rather than privileged instructions.
These foundations prepare the system for the next major subsystem: AI model orchestration, provider abstraction, model routing, prompt construction, token management, fallback handling, and secure AI inference.
export type TextChunk = { index: number; content: string; }; export function chunkText( text: string, maxCharacters = 1800, overlap = 200 ): TextChunk[] { const normalized = text.replace(/\s+/g, " ").trim(); if (!normalized) return []; const chunks: TextChunk[] = []; let start = 0; let index = 0; while (start < normalized.length) { const end = Math.min(start + maxCharacters, normalized.length); chunks.push({ index, content: normalized.slice(start, end).trim(), }); if (end === normalized.length) break; start = Math.max(end - overlap, start + 1); index++; } return chunks; }
export type RetrievalQuery = { userId: string; projectId: string; query: string; limit?: number; }; export type RetrievalResult = { chunkId: string; documentId: string; content: string; score: number; pageNumber?: number; sectionPath?: string[]; }; export interface RetrievalRepository { search(query: RetrievalQuery): Promise<RetrievalResult[]>; }
export async function answerWithRag(input: RetrievalQuery) { // 1. Authenticate the user. // 2. Verify project membership. // 3. Retrieve only authorized chunks. const results = await retrievalRepository.search(input); // 4. Build evidence context. const context = results .map( (item) => `SOURCE: ${item.documentId}\n` + `PAGE: ${item.pageNumber ?? "unknown"}\n` + `CONTENT:\n${item.content}` ) .join("\n\n"); // 5. Send the evidence to the model as untrusted source material. return generateAnswer({ question: input.query, context, }); }
Comments
Post a Comment