Document AI: Turn PDFs, Tables and Scans into Clean RAG Chunks
Hands-on lab · IDE in your browser

Document AI: Turn PDFs, Tables and Scans into Clean RAG Chunks

Build a document ingestion pipeline with pypdf and a vision model: extract page text, strip running headers and footers, repair hyphenated line breaks, turn table rows into self-describing records, transcribe a scanned page, and produce chunks whose evidence can be found and retrieved.

Time
55 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit55 min · 5 stepsSession running
3 / 5 steps passingRead the scanned page · step 4 of 5
ingest.py▶ Run✓ Check
def page_image(folder, doc, page):    """The bytes of the first image on the page (a scan is one big JPEG)."""    return pypdf.PdfReader(os.path.join(folder, doc)).pages[page - 1].images[0].data  def transcribe(image_bytes):    """Read a scanned page with the vision model: one user message holding TRANSCRIBE_PROMPT and the image as a    base64 data URL (data:image/jpeg;base64,...). Returns the transcription."""  
TerminalOutput

The job

Brightwater Housing Association's tenant assistant has to answer from the documents the association actually sends: a two-page handbook with headers, footers and hyphenated line breaks, a rent table, and a rent review letter that was printed, signed and scanned. You build the ingestion pipeline that turns them into chunks the assistant can use, and measure how many answer sentences survive each stage.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Read the pages

    Brightwater Housing Association wants its tenant assistant to answer from three PDFs: the tenant handbook, the weekly rent schedule and a rent review letter.

    You writeread_pages()needs_ocr()
  2. 2

    Clean the text

    Look at what extraction gave you.

    You writestrip_running_lines()unwrap()
  3. 3

    Tables as records

    The plain text of the rent schedule reads 2-bed flat £112.30 £6.20 £118.50.

    You writeparse_table()row_text()
  4. 4

    Read the scanned page

    The rent review letter was printed, signed and scanned, so its PDF holds one JPEG and no text at all.

    You writetranscribe()
  5. 5

    Chunks you can trust

    Put the stages together.

    You writebuild_chunks()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Read the pages

Brightwater Housing Association wants its tenant assistant to answer from three PDFs: the tenant handbook, the weekly rent schedule and a rent review letter. make_pdfs.py stands in for the upload bucket and writes them into pdfs/ the first time you run anything.

questions.json pairs 15 tenant questions with the exact sentence that answers each one. The run reports how many of those sentences can be found word for word, which tells you whether a sentence survived extraction intact.

A PDF stores positioned text, not paragraphs, and a scanned page stores only a picture. pypdf gives you each page's text layer, and an empty text layer is how you spot a scan.

Do this

1. Write read_pages(folder): for every .pdf in the folder, in name order, one dict per page: {"doc": file name, "page": number from 1, "text": page.extract_text() or ""}.

2. Write needs_ocr(page): True when the page's text, stripped, has fewer than 20 characters.

3. Run. Read the per-page character counts, then the list of sentences that cannot be found yet.

ingest.py, the file you edit82 lines
"""Turns the PDFs in pdfs/ into clean, self-describing chunks for a RAG index."""
import base64
import os
import re

import pypdf

import harness

HEADING = re.compile(r"^\d+\. [A-Z]")


# ---------- Step 1: read the pages ----------
def read_pages(folder="pdfs"):
    """One dict per page of every PDF in the folder, in file-name order: {"doc", "page" (from 1), "text"}."""
    # TODO (Step 1): for every .pdf in sorted(os.listdir(folder)), open it with pypdf.PdfReader and add one dict per
    # page: {"doc": file name, "page": number from 1, "text": page.extract_text() or ""}.
    raise NotImplementedError("Step 1: write read_pages()")


def needs_ocr(page):
    """True when the page has no usable text layer (fewer than 20 characters once stripped)."""
    # TODO (Step 1): True when the stripped text is shorter than 20 characters
    raise NotImplementedError("Step 1: write needs_ocr()")


# ---------- Step 2: clean the text ----------
def strip_running_lines(pages):
    """Copies of the pages without running headers and footers: in a document with two or more pages, a line that
    appears on every page (digits ignored, so "Page 1 of 2" and "Page 2 of 2" count as the same line) is removed."""
    raise NotImplementedError("strip_running_lines() arrives in Step 2")


def unwrap(text):
    """Undo the line wrapping: join words hyphenated at a line end ("comp-" + "leted" -> "completed"), then join each
    line to the one before with a space, except headings ("3. Pets") and the line after a heading, which start new lines."""
    raise NotImplementedError("unwrap() arrives in Step 2")


# ---------- Step 3: tables ----------
def parse_table(layout):
    """Read a table out of layout-mode text, where columns are separated by runs of 2+ spaces.
    The header is the first line with 3 or more cells; rows are the following lines with the same number of cells.
    Returns (rows as dicts keyed by header, every other non-empty line of the page, stripped)."""
    raise NotImplementedError("parse_table() arrives in Step 3")


def row_text(caption, row):
    """One self-describing line per row: "<caption>. Property type: Bedsit; Rent: £78.40; ..." in column order."""
    raise NotImplementedError("row_text() arrives in Step 3")


def layout_text(folder, doc, page):
    """The page's text with its layout kept (pypdf extraction_mode="layout")."""
    return pypdf.PdfReader(os.path.join(folder, doc)).pages[page - 1].extract_text(extraction_mode="layout")


# ---------- Step 4: scanned pages ----------
TRANSCRIBE_PROMPT = "Transcribe this scanned page exactly, line by line. Output only the text on the page."


def page_image(folder, doc, page):
    """The bytes of the first image on the page (a scan is one big JPEG)."""
    return pypdf.PdfReader(os.path.join(folder, doc)).pages[page - 1].images[0].data


def transcribe(image_bytes):
    """Read a scanned page with the vision model: one user message holding TRANSCRIBE_PROMPT and the image as a
    base64 data URL (data:image/jpeg;base64,...). Returns the transcription."""
    raise NotImplementedError("transcribe() arrives in Step 4")


# ---------- Step 5: chunks ----------
def title_of(doc):
    """"tenant-handbook.pdf" -> "Tenant Handbook"."""
    return doc[:-4].replace("-", " ").title()


def build_chunks(folder="pdfs"):
    """Every document as chunks {"doc", "kind", "text"}: a section of prose per heading, one chunk per table row plus
    one for the text around the table, one chunk for a scanned page. Each chunk's text starts with where it is from."""
    raise NotImplementedError("build_chunks() arrives in Step 5")
Provided for you:evaluate.pyharness.pymake_pdfs.pyquestions.json

Frequently asked questions

How do you extract text from a PDF for RAG?

Extract each page's text layer (the lab uses pypdf), then clean it: remove running headers and footers, join hyphenated line breaks and unwrap lines into paragraphs. Pages with no text layer are scans and need OCR.

How should tables be chunked?

Row by row, with the column names written into each row, so a chunk like 'Property type: 2-bed flat; Rent: £112.30; Service charge: £6.20' still says what every number means after it is separated from the table.

Can a vision model replace OCR?

For many documents, yes: the lab sends the scanned page to a vision-language model as a base64 image and gets an exact transcription. Check key facts, because a smaller model in testing misread every pound sign.

How do you detect a scanned page in a PDF?

It has no usable text layer: the extracted text is empty or only a few characters, while the page holds a large image.

PDF ingestion for RAG, stage by stage

Most RAG failures on real documents happen before retrieval: text extracted from PDFs carries page headers and footers, words split at the margin, tables flattened into rows of unlabelled numbers, and scanned pages with no text at all. In this lab you fix each problem in turn with pypdf and a vision model, and measure the effect on a set of questions with known answer sentences: how many of those sentences can be found in the chunks, and how many the embedding search retrieves.