A minimal RAG pipeline
Retrieval-augmented generation needs three things from a document converter: clean text, pieces of it small enough to embed, and enough context on each piece to cite it. This page builds that pipeline with doc2mark in a few lines. The search at the end is a keyword toy so the example runs anywhere; put your embedding model and vector store in its place.
Convert and chunk
from doc2mark import ChunkingConfig, UnifiedDocumentLoader
loader = UnifiedDocumentLoader(ocr_provider=None, judge="none") # nothing leaves the machine
config = ChunkingConfig(max_chunk_size=1200, overlap=150)
records = []
for path in ["report.pdf", "report.docx", "slides.pptx"]:
result = loader.load(path)
for chunk in result.get_chunks(config):
records.append({
"id": f"{path}#{chunk.chunk_index}",
"text": chunk.content,
"source": result.metadata.filename,
"section": " > ".join(chunk.section_hierarchy),
"pages": (chunk.page_start, chunk.page_end),
})
print(len(records), "chunks")
print(records[0]["id"], "|", records[0]["section"], "|", records[0]["pages"])
def search(query, k=3):
"""Keyword overlap: replace with embeddings and a vector store."""
words = set(query.lower().split())
return sorted(records, key=lambda r: -len(words & set(r["text"].lower().split())))[:k]
for hit in search("structured table with merged cells"):
print(hit["id"], hit["pages"], hit["section"])
ProcessedDocument.get_chunks() splits the
document at its title and section headings, packs the items of a section into chunks of about
max_chunk_size characters (items are not cut, so a long paragraph or a table can be larger)
and prepends the end of the previous chunk as overlap. Each Chunk carries its section path and page span, which
is what you store next to the vector. Chunking and token usage describes the options, token-based sizes
and the edge cases.
What the converter already did for retrieval
Tables stay tables. Merged cells are kept as
rowspan/colspanin an HTML table inside the Markdown, and a line break in a cell is<br>, so a row never turns into a run of loose words (Tables).Page furniture is thinned, not guessed away. Bare page numbers are dropped; a running header or footer keeps its first copy and its repeats stay out of
contentand out of the chunks (PDF text structure).Hidden text is not indexed. Invisible text over nothing the page shows (a known prompt injection route) is left out and reported in
metadata.extra["hidden_text"].Reading order follows the layout: multi-column pages are read column by column.
Scanned documents
Turn OCR on for documents that may contain scans or text in pictures. doc2mark OCRs only the pages and pictures that need it, and records what it did:
from doc2mark import UnifiedDocumentLoader
loader = UnifiedDocumentLoader(ocr_provider="tesseract")
result = loader.load("scan.pdf", ocr_images=True)
chunks = result.get_chunks()
print(len(chunks), chunks[0].content[:80])
print(result.metadata.extra.get("ocr_issues")) # None: every image was read
With an LLM provider (ocr_provider="openai" or "vertex_ai") the loader also adds the
provider’s token counts to metadata.extra["token_usage"], for cost accounting. Check
metadata.extra["ocr_issues"] before indexing: it lists images the provider refused, could not
read, or answered with withheld sample values (The result: ProcessedDocument).