Spaces:
Sleeping
Document Ingestion Pipeline - Detailed Explanation
Date: March 26, 2026
System: RBAC-Enforced RAG with Groq + SentenceTransformer
Overview
The ingestion pipeline transforms raw documents into queryable chunks with embeddings and RBAC metadata. It consists of 7 stages designed to preserve document hierarchy while enabling secure, semantic search.
RAW DOCUMENT β PARSE β POST-PROCESS β EXTRACT HIERARCHY β CHUNK β EMBED β STORE
(PDF) (Docling) (ResultPostprocessor) (Tree walk) (512tok) (384dim) (Qdrant)
Stage 1: Document Parsing (Docling)
Input
- File format: PDF, DOCX, Markdown, TXT
- File location: Provided via API endpoint
/admin/ingest - Maximum size: 100MB (configurable)
Process
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β DOCLING PARSER INITIALIZATION β
β β
β DocumentConverter() β
β ββ PDF handler: pdfplumber β
β ββ DOCX handler: python-docx β
β ββ Markdown handler: markdown parser β
β ββ Auto-detects format from extension β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββ
β Validate file β
β β Exists β
β β Readable β
β β Size within limits β
ββββββββββ¬ββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββ
β converter.convert(file_path) β
β β
β Returns: ConversionResult β
β Field: .document β
β Type: DoclingDocument β
ββββββββββ¬ββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββ
β Extract document structure β
β β Heading levels β
β β Table of contents β
β β Section breaks β
β β Inline formatting β
β β Tables & lists β
ββββββββββββββββββββββββββββββββββ
Output
DoclingDocument {
blocks: [ # Structured content blocks
Header, # # Heading 1
Paragraph, # Body text
List, # Bullet/numbered lists
Table, # Tabular data
...
],
metadata: {
title,
author,
created_date,
}
}
Code Location
File: ingestion/docling_parser.py:parse_document()
result = self.converter.convert(path)
return {
"document": result.document,
"text": result.document.export_to_markdown(),
}
Stage 2: Post-Processing (Hierarchy Preservation)
Purpose
Maintain hierarchical structure after parsing. Some documents lose structure during parsing β post-processing restores it.
Process
PARSED DOCLING DOCUMENT
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ResultPostprocessor(result) β
β β
β Analyzes document structure: β
β β Identifies header levels (H1, H2, H3...) β
β β Groups content by section β
β β Preserves nesting relationships β
β β Maintains reading order β
β β Reconstructs table of contents β
ββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β
βΌ
.process() β Returns processed result
β
ββ β
Success: Return structured document
β (hierarchy preserved)
β
ββ β Fail: Use raw document as fallback
(graceful degradation)
Key Features
| Feature | Benefit |
|---|---|
| Header Level Detection | Understand document structure |
| Nesting Preservation | Maintain parent-child relationships |
| Reading Order | Correct text flow especially with multi-column |
| Table/List Handling | Keep tabular data intact |
Code Location
File: ingestion/docling_parser.py:parse_document() (Lines 38-47)
# Post-process result to maintain hierarchical structure
try:
result_postprocessor = ResultPostprocessor(result)
result = result_postprocessor.process()
logger.debug(f"Applied post-processing to {path.name}")
except Exception as e:
logger.warning(f"Post-processing failed, using raw result: {str(e)}")
# Continue with raw result if post-processing fails
Stage 3: Hierarchy Extraction
Purpose
Build a tree representation of the document structure for chunking.
Process
POST-PROCESSED DOCUMENT
β
βΌ
walk_document_tree()
β
ββ Recursive depth-first traversal
β
ββ Extract at each level:
ββ Element type (Header, Paragraph, List, Table)
ββ Content text
ββ Hierarchy depth
ββ Parent element ID
ββ Parent section title
HIERARCHY STRUCTURE:
Depth 0: Document root
β
Depth 1: ββ # Introduction
β β
Depth 2: β ββ ## Background
β β β
Depth 3: β β ββ ### Key Concepts
β β ββ ### Related Work
β β
β ββ ## Methodology
β
Depth 1: ββ # Results
β
Depth 2: ββ ## Findings
Generated Hierarchy Data
[
(0, {
"id": "doc_001_root",
"type": "Document",
"text": "Overall content...",
"depth": 0,
}, None), # parent_id = None (root)
(1, {
"id": "doc_001_h1_intro",
"type": "Header",
"text": "Introduction",
"depth": 1,
"is_heading": True,
}, "doc_001_root"), # parent = root
(2, {
"id": "doc_001_h2_bg",
"type": "Header",
"text": "Background",
"depth": 2,
"is_heading": True,
}, "doc_001_h1_intro"), # parent = intro
(2, {
"id": "doc_001_p_bg_content",
"type": "Paragraph",
"text": "The background explains...",
"depth": 2,
}, "doc_001_h2_bg"), # parent = background section
]
Code Location
File: ingestion/docling_parser.py:extract_hierarchy()
Method: _walk_document_tree() (recursive)
Stage 4: Hierarchical Chunking
Purpose
Break document into semantic chunks while preserving context and hierarchy.
Process
Step 1: Split into Paragraphs
CLEANED DOCUMENT TEXT
β
βΌ
Split by:
ββ Markdown headers (#, ##, ###, etc.)
ββ Double newlines (paragraph breaks)
ββ Logical section boundaries
Output: List of paragraphs
Step 2: Build Section Summaries
PARAGRAPHS
β
βΌ
Group by hierarchy level:
ββ Section 1 (H1: Introduction)
β β
β ββ Subsection 1.1 (H2: Background)
β β ββ Content paragraph
β β ββ Content paragraph
β β ββ Content paragraph
β β
β ββ Subsection 1.2 (Methodology)
β ββ [paragraphs]
β
ββ Section 2 (H1: Results)
ββ [paragraphs]
Result: Section metadata for context injection
Step 3: Tokenize & Split
EACH PARAGRAPH/SECTION
β
βΌ
Count tokens
β
βββββ΄βββββββ
β β
< 512 tok β₯ 512 tok
β β
ββ Keep ββ Split recursively
β
ββ One chunk
Step 4: Apply Overlap
CHUNKS:
Chunk 1: [Tok 0-512]
ββ
βββ Overlap region (20%)
β
Chunk 2: [Tok 410-922] β Starts at 410 (20% overlap)
ββ
βββ Overlap region (20%)
β
Chunk 3: [Tok 738-1250] β Starts at 738 (20% overlap)
Benefits:
β Context continuity
β Semantic coherence
β Prevents mid-sentence cuts
β Enables cross-chunk relationships
Configuration
# From config.py
CHUNKING_CONFIG = {
"max_leaf_chunk_tokens": 512, # Max tokens per chunk
"overlap_tokens": 102, # ~20% for 512-tok chunks
"min_chunk_tokens": 50, # Skip tiny chunks
"chunk_type_detection": True, # Detect paragraph types
}
Output: Chunk Objects
Chunk {
id: "finance_policy_chunk_001",
text: "The financial policy states...",
source_document: "finance_policy.pdf",
collection: "finance",
access_roles: ["finance", "c_level"],
# Hierarchy context
section_title: "Financial Policies",
subsection_title: "Investment Guidelines",
depth: 2,
parent_chunk_id: "finance_policy_chunk_000",
parent_summary: "This section covers key policy areas...",
# Content type
chunk_type: "paragraph",
page_number: 5,
}
Code Location
File: ingestion/hierarchical_chunker.py:chunk_document()
Key Methods:
_split_into_paragraphs()β Split by structure_split_paragraph_into_chunks()β Tokenize_build_section_summaries()β Create context
Stage 5: Add RBAC Metadata
Purpose
Attach role-based access control information to chunks.
Process
CHUNK FROM STAGE 4
β
βΌ
SET ACCESS CONTROL
ββββββββββββββββββββββββββββββββββ
β collection β access_roles map β
β β
β Collection: "finance" β
β Maps to roles: β
β ββ "finance" (direct access) β
β ββ "c_level" (executive) β
ββββββββββ¬ββββββββββββββββββββββββ
β
βΌ
ADD METADATA FILTERS
ββββββββββββββββββββββββββββββββββ
β { β
β "collection": "finance", β
β "access_roles": [ β
β "finance", β
β "c_level" β
β ], β
β "section_title": "...", β
β "chunk_type": "paragraph", β
β "source_doc": "policy.pdf" β
β } β
ββββββββββ¬ββββββββββββββββββββββββ
β
βΌ
GENERATE UNIQUE ID
finance_policy_001 (deterministic hash)
RBAC Role-to-Collection Mapping
ROLE_COLLECTION_ACCESS = {
"employee": ["general"],
"finance": ["general", "finance"],
"engineering": ["general", "engineering"],
"marketing": ["general", "marketing"],
"c_level": ["general", "finance", "engineering", "marketing", "hr"],
}
Enforcement: When searching, filter by:
Qdrant filter: {
"access_roles": {"any": [user_role]}
}
Only chunks marked as accessible by the user's role will be returned.
Stage 6: Generate Embeddings
Purpose
Convert chunk text to semantic vectors for similarity search.
Process
CHUNKS WITH METADATA
β
βΌ
FOR EACH CHUNK:
ββ chunk.text
β
βΌ
SentenceTransformer(
model="all-MiniLM-L6-v2"
)
β
ββ Input: Text string
β (max ~512 tokens, already chunk size)
β
ββ Processing:
β 1. Tokenize (subwords)
β 2. Embed with transformer
β 3. Pool: extract [CLS] token
β 4. Normalize: L2 normalization
β
βΌ
OUTPUT: 384-dimensional vector
[0.234, -0.156, 0.892, ..., 0.123]
(384 float values)
PERFORMANCE:
βββββββββββββββββββββββββββββββββββββββ
β Latency: ~10ms per chunk β
β Model size: ~80MB (on disk) β
β Memory: ~200MB (loaded) β
β Cost: FREE (local) β
β Alternative: OpenAI (optional) β
β - Cost: $0.02/1M tokens β
β - Latency: 100ms per chunk β
β - Dimensions: 1536 (larger) β
βββββββββββββββββββββββββββββββββββββββ
Why SentenceTransformer:
β Local inference (no API calls)
β Fast (10x faster than API)
β Free (no per-token cost)
β Privacy (no data sent to OpenAI)
β Offline capable (works without internet)
β Proven for semantic search (384 dims sufficient)
Vector + Metadata Package
PointStruct {
id: 12345,
vector: [0.234, -0.156, ..., 0.123], # 384 floats
payload: {
"chunk_text": "The policy...",
"source_document": "finance_policy.pdf",
"collection": "finance",
"access_roles": ["finance", "c_level"],
"section_title": "Investment Guidelines",
"chunk_type": "paragraph",
"depth": 2,
}
}
Code Location
File: vector_store.py:embed_chunks()
model = SentenceTransformer("all-MiniLM-L6-v2")
embeddings = model.encode(
[chunk.text for chunk in chunks]
) # Returns: List[List[float]] (384-dim vectors)
Stage 7: Store in Qdrant Vector Database
Purpose
Index vectors and metadata for fast semantic search with RBAC filtering.
Process
EMBEDDINGS + METADATA
β
βΌ
ββββββββββββββββββββββββββββββββββββββββ
β QDRANT COLLECTION SETUP β
β β
β collection_name: "document_chunks"β
β vector_size: 384 β
β distance_metric: cosine β
β indexing_config: HNSW β
ββββββββββββ¬ββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββ
β ADD POINTS TO INDEX β
β β
β for each chunk: β
β ββ Point ID (sequential) β
β ββ Vector (384 floats) β
β ββ Metadata payload β
β β ββ access_roles: [...] β
β β ββ collection: "finance" β
β β ββ ... (other fields) β
β β β
β ββ Insert into index β
ββββββββββββ¬ββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββ
β BUILD VECTOR INDEX β
β β
β Algorithm: HNSW β
β (Hierarchical Navigable Small World)
β β
β Benefits: β
β β Fast approximate search β
β β Memory efficient β
β β Scales to millions of vectors β
β β Sub-millisecond queries β
ββββββββββββ¬ββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββ
β READY FOR SEARCH β
β β
β Search query: β
β ββ Embed query (SentenceTransformer)
β ββ Find similar vectors (HNSW) β
β ββ Filter by access_roles β
β ββ Return top-k chunks β
ββββββββββββββββββββββββββββββββββββββββ
Qdrant Storage Structure
Collection: document_chunks
Vector Config:
size: 384
distance: cosine
hnsw:
m: 16 # Connections per node
ef_construct: 200
ef: 100
Points:
- id: 1
vector: [0.234, -0.156, ..., 0.123]
payload:
chunk_text: "..."
source_document: "annual_budget_report.docx"
collection: "finance"
access_roles: ["finance", "c_level"]
section_title: "Investment Guidelines"
chunk_type: "paragraph"
depth: 2
page_number: 5
- id: 2
vector: [0.445, 0.678, ..., -0.234]
payload:
# ... similar structure
Query-Time Search
USER QUERY (e.g., "What's the financial policy?")
β
βΌ
EMBED QUERY
model.encode("What's the financial policy?")
β
βΌ
[0.123, 0.456, ..., 0.789] (384 floats)
β
βΌ
QDRANT SEARCH
{
"vector": [0.123, 0.456, ..., 0.789],
"limit": 5,
"filter": {
"access_roles": {
"any": ["finance"] # User role
}
}
}
β
βΌ
RESULTS (top-5 by similarity):
[
{id: 1, score: 0.92, payload: {...}},
{id: 5, score: 0.87, payload: {...}},
{id: 12, score: 0.81, payload: {...}},
{id: 8, score: 0.79, payload: {...}},
{id: 15, score: 0.76, payload: {...}},
]
Code Location
File: vector_store.py:store_chunks()
client = QdrantClient(":memory:") # or cloud URL
client.upsert(
collection_name="document_chunks",
points=[
PointStruct(
id=chunk_id,
vector=embedding,
payload=chunk_metadata,
)
for chunk_id, embedding in zip(chunk_ids, embeddings)
]
)
End-to-End Data Flow Example
Scenario: Ingesting a Finance PDF
1. UPLOAD STAGE
File: /uploads/annual_budget_report.docx
Size: 2.5 MB
Type: DOCX
2. PARSE STAGE (Docling)
β Converted to DoclingDocument
β Extracted: 250 paragraphs, 15 tables, 8 sections
β Hierarchy: 3 levels deep (H1, H2, H3)
3. POST-PROCESS STAGE
β ResultPostprocessor applied
β Hierarchy preserved
β Headers recognized: H1 (3), H2 (8), H3 (15)
4. EXTRACT HIERARCHY
β Tree built: 26 nodes
β Parent-child relationships: 23
β Depth levels: 0-3
5. CHUNK STAGE
β Split into 50 paragraphs
β Applied overlap: 20%
β Created chunks:
- avg_size: 256 tokens
- count: 47 chunks
- min_size: 50 tokens
- max_size: 512 tokens
6. METADATA STAGE
β Collection: "finance"
β Access roles: ["finance", "c_level"]
β Unique IDs: financial_policy_2024_001, ..., _047
7. EMBEDDING STAGE
β Model: all-MiniLM-L6-v2
β Encoded 47 chunks
β Total time: ~470ms (10ms per chunk)
β Vectors: 47 Γ 384 float array
8. STORE STAGE
β Created Qdrant points
β Added to collection: document_chunks
β Indexed for search
β Ready for queries
FINAL RESULT:
β
47 searchable chunks
β
Full hierarchy preserved
β
RBAC enforced at search time
β
Latency: ~500ms (parsing + chunking + embedding)
β
Cost: FREE (all local operations)
Configuration & Tuning
Chunking Parameters
CHUNKING_CONFIG = {
"max_leaf_chunk_tokens": 512,
"overlap_tokens": 102, # 20% of 512
"min_chunk_tokens": 50,
"chunk_type_detection": True,
}
Impact:
- Larger chunks (512+): Better context, fewer chunks, higher latency
- Smaller chunks (<256): More chunks, better granularity, may split sentences
- Higher overlap (30%): Better context preservation, more redundancy
- Lower overlap (10%): Fewer chunks, may lose context at boundaries
Embedding Model Selection
| Model | Dimensions | Speed | Cost | Use Case |
|---|---|---|---|---|
| all-MiniLM-L6-v2 | 384 | 10ms | FREE | β Default (balanced) |
| all-mpnet-base | 768 | 20ms | FREE | Slower but more accurate |
| all-MiniLM-L12-v2 | 384 | 15ms | FREE | Better accuracy than L6 |
| OpenAI embedding | 1536 | 100ms | $0.02/M | Deprecated (costly) |
Qdrant Configuration
QDRANT_CONFIG = {
"vector_size": 384,
"distance": "cosine", # Semantic similarity
"hnsw": {
"m": 16, # Connections per node
"ef_construct": 200,
"ef": 100,
}
}
# Memory mode (dev/testing)
client = QdrantClient(":memory:")
# Persistent (production)
client = QdrantClient("./qdrant_storage")
# Cloud / Production (Persistent & Managed)
# Mandatory for free-tier hosting (Hugging Face Spaces) to persist data
client = QdrantClient(
url=os.getenv("QDRANT_URL"),
api_key=os.getenv("QDRANT_API_KEY"),
prefer_grpc=True
)
Performance Metrics
Ingestion Times (per 100 chunks)
| Stage | Time | % of Total |
|---|---|---|
| Parse (PDF) | 200ms | 15% |
| Post-process | 50ms | 4% |
| Extract hierarchy | 30ms | 2% |
| Chunk | 100mm | 7% |
| Metadata | 20ms | 1% |
| Embed | 1000ms | 71% |
| Store (Qdrant) | 30ms | 2% |
| TOTAL | 1430ms | 100% |
Bottleneck: Embedding generation (SentenceTransformer)
Optimization: Batch encode all chunks at once (vs. one-by-one)
Storage Size (per 100 chunks)
| Component | Size |
|---|---|
| Raw text | 50 KB |
| Metadata | 5 KB |
| Embeddings (384 Γ 100 floats) | 150 KB |
| Qdrant index overhead | 50 KB |
| TOTAL | ~255 KB |
For 10,000 chunks: ~25 MB (easily fits in memory)
Error Handling & Graceful Degradation
Stage-by-Stage Resilience
Parse Error
ββ Corrupted PDF
ββ Unsupported format
ββ β Log & skip file
Post-process Error
ββ Hierarchy extraction fails
ββ β Use raw document (degraded)
Chunking Error
ββ Text encoding fails
ββ β Use whole text as one chunk
Embedding Error
ββ SentenceTransformer fails
ββ β Log & skip (user alerted)
Storage Error
ββ Qdrant unavailable
ββ β Queue for later ingestion
(persist to disk)
Fallback Strategy
# If post-processing fails
try:
result = ResultPostprocessor(result).process()
except Exception:
result = raw_result # Use raw parse
# If embedding fails
try:
embeddings = model.encode(chunks)
except Exception:
embeddings = dummy_embeddings # Use fallback
log_error()
# If Qdrant store fails
try:
client.upsert(...)
except Exception:
save_to_pending_queue()
schedule_retry()
Security: RBAC at Ingestion Time
Access Control Metadata
Every chunk stores the roles that can access it:
chunk.access_roles = ["finance", "c_level"]
Multi-Layer Enforcement
| Layer | When | How |
|---|---|---|
| Ingestion | Document added | Assign to collection with roles |
| Retrieval | Search query | Filter by user role |
| Database | Vector search | Qdrant filter by access_roles |
| Response | Return results | Only approved chunks |
Example
# Finance department adds confidential budget document
chunk = Chunk(
collection="finance",
access_roles=["finance", "c_level"], # Only these roles
)
# Later: Employee searches
# User role = "employee"
# Qdrant filter: access_roles contains "employee"?
# β NO β 0 results (cannot see this chunk)
# Later: CFO searches
# User role = "c_level"
# Qdrant filter: access_roles contains "c_level"?
# β YES β Document returned
Summary
Ingestion Pipeline: 7 Stages
- Parse β Docling converts file to structured document
- Post-Process β ResultPostprocessor maintains hierarchy
- Extract β Walk tree, build parent-child relationships
- Chunk β Split into ~512-token chunks with 20% overlap
- Metadata β Add RBAC roles and collection info
- Embed β SentenceTransformer generates 384-dim vectors
- Store β Qdrant indexes vectors + metadata for search
Key Features
β
Preserves document hierarchy
β
Enforces RBAC at chunk level
β
Fast local embeddings (10x better than OpenAI API)
β
Graceful error handling (fallbacks at each stage)
β
Efficient storage (~250KB per 100 chunks)
β
Production-ready with proper logging
Performance
- Latency: ~1.4 seconds per 100 chunks
- Cost: FREE (all local operations)
- Throughput: ~50-70 chunks/second (limited by embedding)
- Storage: ~2.5 MB per 10,000 chunks
Next: Use ingested chunks for semantic retrieval in RAG pipeline!