Quick Start: Get Docling Running in 5 Minutes
Six months ago, I was pulling my hair out trying to get a RAG system to answer questions about financial reports. The PDFs had dense tables — quarterly revenue breakdowns, cost matrices, comparison grids — and every chunking strategy I tried turned those tables into garbage. The LLM kept hallucinating numbers it couldn’t actually read.
That’s when I switched to Docling, an open-source document parsing library from IBM Research. It changed how I think about document ingestion entirely.
Install it:
pip install docling
Then parse your first PDF in three lines:
from docling.document_converter import DocumentConverter
converter = DocumentConverter()
result = converter.convert("report.pdf")
print(result.document.export_to_markdown())
That’s it. Docling detects tables, preserves their row-column structure, and exports clean Markdown. Compare this to raw PyMuPDF or pdfplumber output — those libraries flatten a 5-column revenue table into a single wall of concatenated text. Docling gives you actual cells.
How Docling Handles Tables Differently
Most PDF parsers treat a page as a stream of characters. They grab text in reading order and hope for the best. Tables shatter that assumption — cells span rows, headers repeat, columns align visually but not semantically.
Docling runs a multi-stage pipeline under the hood:
- Layout detection: A deep learning model (DocLayNet) identifies regions — paragraphs, tables, figures, headers
- Table structure recognition: A second model (TableFormer) reconstructs the row/column grid from detected table regions
- Text extraction: OCR or native PDF text extraction fills in cell content
- Document assembly: Everything gets packaged into a structured
DoclingDocumentobject
Two neural models instead of regex heuristics. That’s why it handles scanned PDFs, multi-column layouts, and merged cells far better than rule-based parsers.
Accessing Table Data Programmatically
Markdown export is handy for eyeballing output, but RAG pipelines need structured access. Here’s how to iterate over every table in a document:
from docling.document_converter import DocumentConverter
converter = DocumentConverter()
result = converter.convert("annual_report.pdf")
doc = result.document
for table_idx, table in enumerate(doc.tables):
print(f"Table {table_idx}: {table.num_rows} rows x {table.num_cols} cols")
# Export as pandas DataFrame
df = table.export_to_dataframe()
print(df.head())
print("---")
I use DataFrames constantly at this stage. They let me validate the extraction, run sanity checks on numeric columns — spot a revenue column that parsed as strings, for instance — and decide how to chunk the data before indexing.
Exporting Tables as Structured Text for Embedding
Raw DataFrames don’t embed well. You need to convert them into text that preserves context. My go-to approach:
def table_to_context_string(table, doc_title="", page_num=None):
"""Convert a Docling table to a context-rich string for embedding."""
df = table.export_to_dataframe()
lines = []
if doc_title:
lines.append(f"Source: {doc_title}")
if page_num:
lines.append(f"Page: {page_num}")
headers = " | ".join(str(col) for col in df.columns)
lines.append(f"Columns: {headers}")
for _, row in df.iterrows():
row_parts = [f"{col}: {val}" for col, val in row.items() if str(val).strip()]
lines.append(", ".join(row_parts))
return "\n".join(lines)
for table in doc.tables:
context_str = table_to_context_string(
table,
doc_title="Q3 2024 Financial Report",
page_num=table.prov[0].page_no if table.prov else None
)
print(context_str[:300])
Here’s the non-obvious part — it took me a few weeks to figure this out. Embedding a raw CSV-like string performs noticeably worse than embedding a natural language representation of the same data. Adding document title and page number also tightens retrieval precision: in my tests, precision@5 jumped from 61% to 74% just by including those two metadata fields in the chunk text.
Building a Full PDF Table Ingestion Pipeline
Below is the pipeline I run in production for a knowledge base that ingests technical specification documents every week — typically 20–40 PDFs per batch.
Step 1: Batch Process Multiple PDFs
from pathlib import Path
from docling.document_converter import DocumentConverter
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import PdfPipelineOptions
pipeline_options = PdfPipelineOptions()
pipeline_options.do_ocr = False # Set True for scanned PDFs
pipeline_options.do_table_structure = True
pipeline_options.table_structure_options.do_cell_matching = True
converter = DocumentConverter(
format_options={
InputFormat.PDF: pipeline_options
}
)
pdf_dir = Path("./documents")
results = converter.convert_all(
[str(p) for p in pdf_dir.glob("*.pdf")],
raises_on_error=False # Skip failed files instead of crashing
)
for result in results:
if result.status.name == "SUCCESS":
print(f"OK: {result.input.file.name} — {len(result.document.tables)} tables")
else:
print(f"FAIL: {result.input.file.name} — {result.status}")
Step 2: Chunk and Index with a Vector Store
from docling.chunking import HybridChunker
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-small-en-v1.5")
chunker = HybridChunker(
tokenizer=tokenizer,
max_tokens=512,
merge_peers=True
)
all_chunks = []
for result in results:
if result.status.name != "SUCCESS":
continue
doc = result.document
chunks = list(chunker.chunk(doc))
for chunk in chunks:
chunk_text = chunker.serialize(chunk=chunk)
metadata = {
"source": result.input.file.name,
"page": chunk.meta.doc_items[0].prov[0].page_no
if chunk.meta.doc_items and chunk.meta.doc_items[0].prov
else None,
"is_table": any(
item.label == "table"
for item in chunk.meta.doc_items
)
}
all_chunks.append((chunk_text, metadata))
print(f"Total chunks: {len(all_chunks)}")
print(f"Table chunks: {sum(1 for _, m in all_chunks if m['is_table'])}")
HybridChunker is the most underrated feature in Docling. It doesn’t blindly split by token count — it respects document hierarchy. A table stays in one chunk rather than getting sliced mid-row. On my internal benchmark, this single change improved precision@5 by about 18% compared to naive token-window chunking.
Step 3: Store and Query with ChromaDB
import chromadb
from chromadb.utils import embedding_functions
client = chromadb.PersistentClient(path="./chroma_db")
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="BAAI/bge-small-en-v1.5"
)
collection = client.get_or_create_collection(
name="pdf_documents",
embedding_function=embedding_fn
)
texts = [c[0] for c in all_chunks]
metadatas = [c[1] for c in all_chunks]
ids = [f"chunk_{i}" for i in range(len(all_chunks))]
collection.add(documents=texts, metadatas=metadatas, ids=ids)
# Filter to table chunks when the query is clearly numerical
results = collection.query(
query_texts=["What was the revenue in Q3?"],
n_results=5,
where={"is_table": True}
)
for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
print(f"[{meta['source']} p.{meta['page']}]")
print(doc[:200])
print()
That where={"is_table": True} filter is worth wiring up properly. For numerical or comparative queries, restricting to table chunks cuts down irrelevant paragraph matches. I route queries through a simple classifier first — if the question contains numbers, comparisons, or words like “highest”/”lowest”/”total”, it hits the table-only subset.
Practical Tips from 6 Months of Production
Nobody told me any of this when I started. Here’s the short version of what I learned the hard way:
1. Always Validate Table Extraction Quality
Docling does well, but not perfectly. A quick validation step catches the obvious failures before they pollute your index:
def validate_table(df):
issues = []
if df.empty:
issues.append("empty table")
if df.shape[1] < 2:
issues.append("single column — likely misdetected")
if df.isnull().sum().sum() / df.size > 0.5:
issues.append("over 50% null — likely OCR failure")
return issues
2. Scanned PDFs Need OCR — But at a Cost
Enable OCR only when you have to. Native PDFs parse roughly 10× faster with do_ocr=False. Before parsing, I check whether PyMuPDF finds any text layer in the first two pages — if it does, I skip OCR entirely.
3. Use Markdown Format When Passing Tables to an LLM
When retrieved chunks go into an LLM context window, Markdown table format outperforms CSV or JSON. Models trained on instruction data have seen thousands of GitHub READMEs and documentation pages — they handle | col1 | col2 | syntax natively.
# Docling Markdown export preserves table formatting automatically
markdown_output = result.document.export_to_markdown()
# Tables render as proper | col1 | col2 | Markdown tables
4. Cache Parsed Results — Seriously
PDF parsing with table structure recognition is slow. A 40-page technical spec can take 45 seconds. Cache everything:
import json
from pathlib import Path
# Save once
result.document.save_as_json(Path("cache") / f"{pdf_path.stem}.json")
# Load on subsequent runs (under 1 second)
from docling.datamodel.document import DoclingDocument
cached_doc = DoclingDocument.load_from_json(Path("cache/report.json"))
Cold parse: ~45 seconds per document. Warm cache: under a second. For a weekly ingestion job touching 30 files, most of which haven’t changed, this is the difference between a 22-minute job and a 30-second one.
5. Drop-in Integration with LangChain or LlamaIndex
Already on LangChain? Don’t rewrite your pipeline. Use the official loader:
pip install langchain-docling
pip install llama-index-readers-docling
from langchain_docling import DoclingLoader
loader = DoclingLoader(file_path="report.pdf")
docs = loader.load()
# Each Document includes page_content and metadata with table markers
Lowest-friction path. If you already have a LangChain retriever wired up, this slots straight in.
After running this across hundreds of financial and technical PDFs, table extraction quality is the single biggest lever for RAG accuracy on document-heavy queries.
The answer quality difference between naive chunking and a proper Docling pipeline is obvious the first time you ask a numerical question and get a correct, cited response instead of a hallucinated one. Docling v2 is considerably more stable than the version I started with — and the GitHub issues are actually getting closed, which is a good sign.

