A typical scenario: a client uploads a 500-page PDF with tables and multi-column layout, and the RAG system returns broken answers—column text merged, headings lost, tables turned into mush. We know how to avoid this: quality parsing is the foundation of any RAG pipeline. Over 5 years, we've handled more than 200 projects of varying complexity and have seen firsthand that skimping on parsing leads to lost answer accuracy.
Why Parsing Quality Determines RAG Success
Modern RAG systems, such as those built on LangChain or LlamaIndex, require clean, structured text for correct chunking and embedding. If the input is a mess, the search will be chaotic. Studies show that up to 30% of RAG errors are caused by poor document parsing. We use a stack: PyTorch for custom models, pdfplumber for PDFs, BeautifulSoup and markdownify for HTML, python-docx for DOCX.
Which Formats We Support – Document Indexing for
| Format | Parsing Complexity | Features |
|---|---|---|
| High | Tables, columns, scans (OCR) | |
| DOCX | Medium | Embedded tables, styles |
| HTML | Low | Garbage tags, scripts |
| Markdown | Low | Ready headings and lists |
How We Parse Complex PDFs
Take a real case: a PDF with financial statements—200 pages, each with a table of financial data. Standard libraries like PyPDF2 or pdfminer lose cell boundaries. We use pdfplumber with custom post-processing:
from pathlib import Path
from dataclasses import dataclass
@dataclass
class ParsedDocument:
text: str
metadata: dict
source_format: str
page_count: int = None
class DocumentParser:
def parse(self, file_path: str) -> ParsedDocument:
path = Path(file_path)
ext = path.suffix.lower()
if ext == '.pdf':
return self._parse_pdf(file_path)
elif ext in ['.docx', '.doc']:
return self._parse_docx(file_path)
elif ext in ['.html', '.htm']:
return self._parse_html(file_path)
elif ext in ['.md', '.markdown']:
return self._parse_markdown(file_path)
else:
raise ValueError(f"Unsupported format: {ext}")
def _parse_pdf(self, path: str) -> ParsedDocument:
# For complex PDFs (with tables, columns) — pdfplumber
import pdfplumber
with pdfplumber.open(path) as pdf:
pages_text = []
for page in pdf.pages:
# Save tables as markdown
tables = page.extract_tables()
text = page.extract_text() or ""
for table in tables:
table_md = self._table_to_markdown(table)
text += f"\n\n{table_md}\n\n"
pages_text.append(text)
full_text = "\n\n---PAGE BREAK---\n\n".join(pages_text)
return ParsedDocument(
text=full_text,
metadata={"source": path, "pages": len(pdf.pages)},
source_format="pdf",
page_count=len(pdf.pages)
)
def _parse_docx(self, path: str) -> ParsedDocument:
from docx import Document
doc = Document(path)
elements = []
for element in doc.element.body:
if element.tag.endswith('p'): # Paragraph
para = element
style = para.style.name if hasattr(para, 'style') else ''
text = element.text_content()
if style.startswith('Heading'):
level = int(style.split()[-1]) if style[-1].isdigit() else 1
elements.append('#' * level + ' ' + text)
elif text.strip():
elements.append(text)
elif element.tag.endswith('tbl'): # Table
table = self._extract_table_from_docx(element)
elements.append(table)
return ParsedDocument(
text='\n\n'.join(elements),
metadata={"source": path},
source_format="docx"
)
def _parse_html(self, path: str) -> ParsedDocument:
from bs4 import BeautifulSoup
with open(path, 'r', encoding='utf-8') as f:
soup = BeautifulSoup(f.read(), 'html.parser')
# Remove scripts and styles
for tag in soup(['script', 'style', 'nav', 'footer', 'header']):
tag.decompose()
# Extract structured text
from markdownify import markdownify
text = markdownify(str(soup), heading_style="ATX")
return ParsedDocument(
text=text,
metadata={"source": path, "title": soup.title.string if soup.title else ""},
source_format="html"
)
Structured Metadata Extraction
class MetadataExtractor:
def extract(self, doc: ParsedDocument) -> dict:
metadata = doc.metadata.copy()
# Extract headings for navigation
headers = re.findall(r'^#{1,3}\s+(.+)$', doc.text, re.MULTILINE)
metadata['headers'] = headers[:20] # First 20 headings
# Extract dates
date_pattern = r'\b\d{1,2}[./]\d{1,2}[./]\d{2,4}\b'
dates = re.findall(date_pattern, doc.text)
if dates:
metadata['dates_mentioned'] = dates[:5]
# Document language
from langdetect import detect
try:
metadata['language'] = detect(doc.text[:1000])
except Exception:
metadata['language'] = 'unknown'
return metadata
Preparation for Indexing
After parsing, documents are chunked, embedded, and loaded into a vector DB. Key point: preserving structural markers (headings, page numbers) in chunk metadata ensures source attribution in RAG answers.
For a 1000-page PDF, the full cycle (parsing → chunking → embedding → indexing) takes 5–15 minutes when using OpenAI Embeddings API. Our own GPUs on Triton Inference Server speed up embedding by 2–3x.
What's Included
- Document audit: analysis of types, volume, complexity.
- Pipeline development: parsers, chunker, embedder, loader.
- Integration with vector database: Qdrant, ChromaDB, pgvector—your choice.
- Testing on metrics: recall@k, precision@k, latency p99.
- Documentation and training: code handover, architecture description, team training.
- Support: 3 months warranty on bugs and adaptation for new formats.
Comparison: Off-the-Shelf Services vs Custom Solution
| Criterion | Off-the-shelf (e.g., Unstructured.io) | Our custom solution |
|---|---|---|
| Table extraction quality | Average (up to 70%) | High (95%+) |
| Support for rare formats | Limited | Any format on request |
| Control over metadata | Minimal | Full control |
| Cost for 10,000 pages | ~$500/month | One-time + support |
| Integration with your stack | Via API | Deep embedding |
Process
- Analysis: you send 2–3 sample documents, we assess complexity and timeline.
- Design: choose stack (Hugging Face Embeddings, vLLM, etc.), design pipeline.
- Implementation: write parser code and integrate with your RAG system.
- Testing: run on your data, fine-tune chunking and embeddings.
- Deployment: deploy in your infrastructure (AWS, GCP, on-prem).
Estimated timeline: from 2 weeks to 2 months depending on volume and complexity. Price is calculated individually per project.
Chunking Strategies: How Splitting Affects RAG Accuracy
The choice of chunking strategy directly impacts recall@5 in your RAG. Large chunks (2000+ tokens) reduce search accuracy. Tiny chunks (64 tokens) lose context.
Proven strategies:
- Fixed-size with overlap: 512-token chunks, 64 token overlap. Good for homogeneous text without complex structure.
- Sentence window: chunk = sentence + 2–3 surrounding sentences. High recall, suitable for FAQs.
- Heading-based: split by document headings. Ideal for technical documentation and regulations.
- Semantic chunking: cut at semantic boundaries (SBERT cosine similarity). Best quality, but requires extra computation.
We test several strategies on your documents and choose based on recall@5 and MRR metrics.
Get a consultation — send us sample documents, and we'll give you an estimate within 1 business day. Our experience: 200+ projects, 5 years on the market, quality guarantee at every stage.







