Finbot-backend / app /backend /INGESTION_PROCESS.md
Srini P
Fresh cleaner push without any mp4
e7586f8
|
Raw
History Blame Contribute Delete
25.9 kB
# 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
```python
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()`
```python
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)
```python
# 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
```python
[
(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
```python
# 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
```python
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
```python
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:
```python
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
```python
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()`
```python
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
```yaml
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()`
```python
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
```python
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
```python
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
```python
# 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:
```python
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
```python
# 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**
1. **Parse** β€” Docling converts file to structured document
2. **Post-Process** β€” ResultPostprocessor maintains hierarchy
3. **Extract** β€” Walk tree, build parent-child relationships
4. **Chunk** β€” Split into ~512-token chunks with 20% overlap
5. **Metadata** β€” Add RBAC roles and collection info
6. **Embed** β€” SentenceTransformer generates 384-dim vectors
7. **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!