- Introduction to Document Loading
- The RAG Pipeline Context
- Core Concepts
- Document Structure
- Text Extraction Strategies
- Text Preprocessing Philosophy
- Page Splitting vs. Full Document
- Comparison with LangChain.js
- Design Trade-offs
- When to Use What
- Advanced Concepts
Examples and how they map to the diagram
- 02_data_loading = Knowledge Sources <- you are here
- 03_text_splitting_and_chunking = Chunking
- 04_intro_to_embeddings = Embedding
- 05_building_vector_store = Vector Database
Document loading is the first and foundational step in any Retrieval-Augmented Generation (RAG) system. It's the process of taking unstructured data (PDFs, web pages, documents) and transforming them into a structured, machine-readable format that can be processed by downstream components in the RAG pipeline.
Think of it as the "intake system" of your RAG application - just as a restaurant needs to properly receive, inspect, and prepare ingredients before cooking, a RAG system needs to properly load and structure documents before they can be used to answer questions.
The quality of your document loading directly impacts:
- Retrieval Accuracy: Poor text extraction leads to incomplete or corrupted content
- Embedding Quality: Garbage in, garbage out-messy text creates poor embeddings
- Answer Quality: LLMs can only work with what they're given
- Citation Accuracy: Proper metadata enables precise source attribution
- System Performance: Efficient loading affects the entire pipeline's speed
PDFs and other documents contain:
- Visual formatting (bold, italics, columns)
- Non-textual elements (images, charts, tables)
- Structural artifacts (headers, footers, page numbers)
- Encoding complexities (fonts, character sets)
- Layout information (columns, text boxes)
Document loaders must extract meaningful text while handling these complexities intelligently.
┌─────────────────────────────────────────────────────────────┐
│ RAG PIPELINE │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. DOCUMENT LOADING ← YOU ARE HERE │
│ ├─ Load PDFs, docs, web pages │
│ ├─ Extract raw text │
│ ├─ Clean and normalize │
│ └─ Structure into Documents │
│ │
│ 2. TEXT SPLITTING │
│ ├─ Break large documents into chunks │
│ ├─ Respect semantic boundaries │
│ └─ Add overlap for context │
│ │
│ 3. EMBEDDING GENERATION │
│ ├─ Convert text chunks to vectors │
│ └─ Capture semantic meaning │
│ │
│ 4. VECTOR STORAGE │
│ ├─ Index embeddings in vector DB │
│ └─ Store metadata for retrieval │
│ │
│ 5. RETRIEVAL (at query time) │
│ ├─ Embed user query │
│ ├─ Find similar vectors │
│ └─ Return relevant chunks │
│ │
│ 6. GENERATION │
│ ├─ Combine query + retrieved context │
│ └─ Generate answer with LLM │
│ │
└─────────────────────────────────────────────────────────────┘
PDF File → Document Loader → Structured Documents → Text Splitter → Chunks
↓
(with metadata)
↓
Eventually becomes context for LLM
A Document is a fundamental abstraction in RAG systems. It represents a unit of text with associated metadata:
{
pageContent: "The actual text content...",
metadata: {
source: "where it came from",
page: "which page",
// ... other contextual info
}
}Why This Matters:
- Separation of Concerns: Content is separate from metadata
- Flexibility: Same structure works for PDFs, web pages, CSVs, etc.
- Traceability: Always know where information originated
- Filtering: Can filter retrieval by metadata (e.g., "only search recent docs")
Document loading transforms:
UNSTRUCTURED STRUCTURED
┌──────────────┐ ┌──────────────┐
│ Binary PDF │ │ Document { │
│ with complex │ →→→ │ text: "...",│
│ formatting │ │ metadata │
└──────────────┘ └──────────────┘
This transformation involves:
- Parsing: Understanding the file format
- Extraction: Pulling out text content
- Cleaning: Removing artifacts
- Structuring: Organizing into standard format
Metadata isn't just "extra info"-it's critical for:
- User asks: “What does the paper say about RAG?”
- LLM can respond: “According to page 5 of the paper…”
- This is only possible because we stored page numbers!
- “Search only in documents from 2024”
- “Find information in the Q3 reports”
- Metadata enables these queries
- “Where did this answer come from?”
- Metadata provides the trail back to the source
Granularity refers to how finely you split documents:
COARSE GRANULARITY FINE GRANULARITY
┌──────────────┐ ┌───┬───┬───┬───┐
│ │ │ P │ P │ P │ P │
│ Entire │ vs. │ a │ a │ a │ a │
│ Document │ │ g │ g │ g │ g │
│ │ │ e │ e │ e │ e │
└──────────────┘ └───┴───┴───┴───┘
Trade-offs:
- Coarse: Better context, but less precise retrieval
- Fine: More precise, but may miss context
class Document {
constructor(pageContent, metadata, id) {
this.pageContent = pageContent;
this.metadata = metadata;
this.id = id;
}
}This simple structure embodies several design principles:
- Three fields, clear purpose
- No unnecessary complexity
- Easy to understand and use
pageContentcan be any textmetadatais an open object (any structure)- Works across document types
- Matches LangChain's Document interface
- Interoperable with other RAG tools
- Standard pattern in the ecosystem
- Easy to subclass for special document types
- Metadata can hold domain-specific information
- ID enables tracking and deduplication
The metadata structure in our code:
{
source: "URL or file path", // Origin
pdf: {
info: {...}, // PDF properties
metadata: {...}, // PDF metadata
totalPages: 22 // Total pages
},
loc: { // Location
pageNumber: 1 // Specific page
}
}Design Considerations:
Hierarchical Organization:
- Groups related metadata together
pdfcontains all PDF-specific info- Easy to extend for other formats (add
word: {...})
Balance Between Generic and Specific:
sourceandlocare generic (work for any document)pdfis format-specific (only for PDFs)
Future-Proofing:
- Can add new top-level keys without breaking existing code
- Nested structure allows deep customization
PDFs are fundamentally visual documents-they specify where ink should appear on a page, not the logical structure of text. This creates challenges:
┌─────────────────────────────┐
│ PDF Visual Layout: │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Column 1 │ │ Column 2 │ │
│ │ Text A │ │ Text C │ │
│ │ Text B │ │ Text D │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────┘
Possible Extractions:
1. "Text A, Text B, Text C, Text D" (column-by-column)
2. "Text A, Text C, Text B, Text D" (left-to-right, top-to-bottom)
PDFs contain visual elements that shouldn't be in the extracted text:
- Page numbers ("Page 1 of 22")
- Headers and footers
- Watermarks
- Decorative elements
- Different fonts may encode characters differently
- Special characters (bullets, em-dashes) may not extract cleanly
- Unicode normalization issues
Our code uses pdf-parse, which:
- Extracts text in reading order (best effort)
- Handles common encoding issues
- Provides page-level extraction
The philosophy is:
- Extract first, clean later: Get all text out, then process it
- Preserve semantic structure: Keep paragraph breaks
- Remove noise: Strip out pagination artifacts
- Normalize: Create consistent formatting
Raw PDF text often looks like:
"Retrieval-Augmented Generation for
AI-Generated Content
-- 1 of 22 --
Abstract: This paper surveys..."
After cleaning:
"Retrieval-Augmented Generation for
AI-Generated Content
Abstract: This paper surveys..."
Our cleanText function implements a three-stage cleaning pipeline:
.replace(/[-–-]\s*\d+\s*of\s*\d+\s*[-–-]/gi, '')Purpose: Remove pagination artifacts that add no semantic value
Philosophy: These elements are navigation aids for human readers viewing the PDF, but they're noise for machine processing. They:
- Don't contribute to meaning
- Break up sentences artificially
- Consume tokens needlessly
- Confuse embedding models
Example:
Before: "The study found -- 5 of 12 -- significant results"
After: "The study found significant results"
.replace(/[ \t]+/g, ' ')Purpose: Create consistent spacing
Philosophy: Multiple spaces and tabs are artifacts of PDF rendering. They don't carry semantic meaning in plain text. Normalization:
- Makes text predictable
- Reduces token count
- Improves embedding consistency
- Simplifies downstream processing
Example:
Before: "The results showed significant"
After: "The results showed significant"
.replace(/\n{3,}/g, '\n\n')Purpose: Maintain document structure while removing excess
Philosophy: This is the most subtle but important step. It balances two competing needs:
- Preserve Structure: Paragraph breaks carry semantic meaning (topic changes, logical divisions)
- Remove Noise: Multiple blank lines are rendering artifacts
Why Two Newlines?:
- Standard convention:
\n\n= paragraph break - One newline = line break (within paragraph)
- Two newlines = paragraph boundary
- Three+ newlines = rendering artifact
Example:
Before:
"First paragraph.
Second paragraph."
After:
"First paragraph.
Second paragraph."
Notice what our cleaning doesn't do:
- ❌ Remove punctuation
- ❌ Lowercase everything
- ❌ Remove stop words
- ❌ Lemmatize or stem
- ❌ Fix spelling errors
Why? Because:
- Modern embedding models handle these variations
- Preserves original meaning and author's voice
- Maintains proper nouns and technical terms
- Reduces risk of introducing errors
- Keeps citations accurate
This is conservative preprocessing-remove obvious noise, preserve meaning.
This is one of the most important conceptual decisions in document loading:
┌────────────────────────────────────────────────────────┐
│ GRANULARITY SPECTRUM │
├────────────────────────────────────────────────────────┤
│ │
│ Full Document Pages Paragraphs │
│ ───────────── ───── ─────────── │
│ • Best context • Balanced • Best │
│ • Worst precision • Citations • Precision │
│ • Simple • Flexible • Complex │
│ │
└────────────────────────────────────────────────────────┘
Concept: Treat the entire PDF as a single, monolithic document.
When to Use:
- Short documents (< 10 pages)
- Highly cohesive content (single topic throughout)
- When you'll be doing your own custom chunking later
- Simple use cases
Advantages:
✓ Maximum context preservation
✓ No artificial boundaries
✓ Simpler data structure (1 document)
✓ Faster processing (single extraction)
✓ Good for whole-document questions
Disadvantages:
✗ May exceed embedding model token limits
✗ Less precise retrieval
✗ No page-level citations
✗ Harder to identify relevant sections
✗ Memory intensive for large documents
Example Use Case:
Document: A 5-page company policy on remote work
Strategy: Load as single document
Reason: Short, cohesive, users need full context
Concept: Create one document per page, each with its own metadata.
When to Use:
- Long documents (> 10 pages)
- Academic papers (citations matter)
- Technical manuals (specific page references)
- When you need granular retrieval
Advantages:
✓ Precise page-level citations
✓ Manageable chunk sizes
✓ Better retrieval granularity
✓ Parallel processing possible
✓ Respects natural boundaries
Disadvantages:
✗ Context may split across pages
✗ More documents to manage
✗ Slightly slower processing
✗ Need good chunking strategy later
Example Use Case:
Document: 50-page research paper
Strategy: Split by pages
Reason: Need citations, paper is long, topics vary by section
In practice, pages are often still too large for optimal retrieval. The typical workflow is:
PDF → Load by Pages → Further Split into Chunks → Embed
Example:
1. Load: 50-page PDF → 50 page-documents
2. Split: Each page → 2-4 chunks (semantic boundaries)
3. Result: ~150 chunks, each with page number metadata
This gives you:
- The best of both worlds
- Page-level attribution
- Chunk-level precision
- Context preservation through overlap
LangChain.js is the leading framework for building LLM applications, including RAG systems. Let's compare our custom implementation with LangChain's approach.
LangChain provides a rich ecosystem of document loaders:
import { PDFLoader } from "langchain/document-loaders/fs/pdf";
import { WebPDFLoader } from "langchain/document-loaders/web/pdf";
// URL-based loading
const loader = new WebPDFLoader("https://example.com/paper.pdf");
// File-based loading
const loader = new PDFLoader("./path/to/file.pdf");
// Load documents
const docs = await loader.load();// Custom, explicit
const extractTextFromPDF = async (url, {splitPages} = {splitPages: false}) => {
const parser = new PDFParse({url});
// ... manual processing
return doc;
}Philosophy:
- Explicit control over every step
- Transparency in what happens to the text
- Customization is straightforward
- Learning-focused (understand the process)
// Abstracted, declarative
const loader = new WebPDFLoader(url, {
splitPages: true,
pdfjs: () => import("pdf-parse")
});
const docs = await loader.load();Philosophy:
- Abstraction hides complexity
- Consistency across different loaders
- Batteries included with smart defaults
- Production-focused (get results fast)
| Feature | Our Implementation | LangChain.js |
|---|---|---|
| Learning Curve | Low (simple, clear) | Medium (framework concepts) |
| Flexibility | High (full control) | Medium (within framework) |
| Setup Complexity | Low (one file) | Medium (dependencies) |
| Text Cleaning | Custom, explicit | Built-in, opinionated |
| Error Handling | Manual | Automatic |
| Multiple Formats | Need custom code | 80+ loaders available |
| Integration | Custom pipeline | Seamless with LangChain |
| Production Ready | Need hardening | Yes |
| Documentation | Basic | Extensive |
| Community | DIY | Large ecosystem |
Our Implementation:
class Document {
constructor(pageContent, metadata, id) {
this.pageContent = pageContent;
this.metadata = metadata;
this.id = id;
}
}- Simple, straightforward
- Easy to understand
- Compatible with LangChain (same interface!)
LangChain:
import { Document } from "langchain/document";
// Same structure, but with additional methods
const doc = new Document({
pageContent: "...",
metadata: {...}
});- More features (serialization, etc.)
- Part of larger ecosystem
- Typed (TypeScript definitions)
Our Implementation:
const cleanText = (text) => {
return text
.replace(/[-–-]\s*\d+\s*of\s*\d+\s*[-–-]/gi, '')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}- Explicit: You see exactly what's happening
- Customizable: Easy to add/remove cleaning steps
- Transparent: No hidden transformations
LangChain:
// Cleaning is often handled internally by loaders
// Can customize via TextSplitter configuration
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
// Cleaning happens automatically
});- Automatic: Cleaning is built-in
- Opinionated: Uses framework's defaults
- Hidden: Less visibility into process
Our Implementation:
if (splitPages) {
for (let i = 0; i < pages; i++) {
const rawText = (await parser.getText({partial: [i + 1]})).text;
// ... create page-level document
}
}- Manual loop: Explicit page-by-page processing
- Direct control: Decide exactly how pages are handled
- Visible logic: Easy to debug
LangChain:
const loader = new WebPDFLoader(url, {
splitPages: true // That's it!
});- Declarative: Just specify what you want
- Handled internally: Framework does the work
- Consistent: Same API across loaders
Our Implementation:
{
source: url,
pdf: {
info: info,
metadata: info.metadata,
totalPages: pages,
},
loc: {
pageNumber: i + 1
}
}- Custom structure: Design your own metadata schema
- Explicit construction: You control what's stored
- Full flexibility: Add any metadata you need
LangChain:
// Metadata is automatically populated
{
source: "https://example.com/paper.pdf",
pdf: {
version: "1.4",
info: {...},
metadata: {...},
totalPages: 50
},
loc: { pageNumber: 1 }
}- Standardized: Consistent across loaders
- Automatic: No manual construction needed
- Rich: Includes many useful fields by default
-
Document loading is foundational: Quality here affects everything downstream
-
Simple is often better: Our implementation proves you don't need complexity for effectiveness
-
Understand the trade-offs: Every design decision has pros and cons
-
Start simple, evolve: Begin with basic loading, add sophistication as needed
-
Choose the right tool: Custom for learning, LangChain for production, hybrid for special needs
Phase 1: Learn the concepts (custom implementation)
↓
Phase 2: Understand the challenges (hit edge cases)
↓
Phase 3: Appreciate the frameworks (see why they exist)
↓
Phase 4: Make informed decisions (custom vs. framework)
To deepen your understanding:
- Experiment: Try different cleaning strategies, compare results
- Measure: Add metrics, see what affects quality
- Compare: Load same document with custom code and LangChain
- Extend: Add table extraction, image handling
- Scale: Try loading 100 documents, measure performance
Document loading is where data engineering meets information retrieval. It's:
- Part science (algorithms, strategies)
- Part art (knowing what to preserve)
- Part craft (implementation quality)
Our simple implementation teaches the fundamentals. LangChain provides production power. Understanding both makes you a better RAG engineer.
The best RAG systems don't just load documents-they curate knowledge. And that starts here, at the very first step, with thoughtful, principled document loading.
