n4nAI

Healthcare document Q&A: chunking clinical PDFs correctly

Learn how to chunk clinical PDFs for document Q&A with proper section-aware splitting, table preservation, and metadata tracking for healthcare RAG systems.

n4n Team3 min read729 words

Audio narration

Coming soon — every post will get a voice note here.

Clinical PDFs break naive chunking. A discharge summary has structured sections — HPI, medications, allergies, plan — each with distinct semantics. A lab report embeds tables that lose meaning when split across chunks. Radiology reports nest findings and impressions in ways that demand section-aware boundaries. If you chunk by fixed token count or simple paragraph splits, you destroy the clinical context that makes retrieval useful.

This guide walks through a production-grade pipeline for chunking clinical PDFs: PDF parsing with layout awareness, section detection using medical document structure, table extraction and serialization, and chunk assembly with metadata that supports filtered retrieval. The code runs end-to-end with open-source tools.

Step 1: Parse with layout preservation

PyMuPDF (fitz) gives you block-level layout data — coordinates, font sizes, reading order — which is essential for detecting section headers and table boundaries. pdfplumber is an alternative; both work. We use fitz here for speed and coordinate fidelity.

# pdf_parser.py
import fitz  # pymupdf
from dataclasses import dataclass
from typing import List, Optional
import json

@dataclass
class TextBlock:
    text: str
    bbox: tuple  # (x0, y0, x1, y1)
    page_num: int
    font_size: float
    font_name: str
    is_bold: bool
    block_type: str  # "text", "table", "image"

def extract_blocks(pdf_path: str) -> List[TextBlock]:
    doc = fitz.open(pdf_path)
    blocks = []
    
    for page_num, page in enumerate(doc):
        # Get raw text blocks with font info
        raw_blocks = page.get_text("dict")["blocks"]
        
        for block in raw_blocks:
            if block["type"] == 0:  # text block
                for line in block["lines"]:
                    line_text = " ".join(span["text"] for span in line["spans"])
                    if not line_text.strip():
                        continue
                    
                    # Use first span for font metadata (good enough for headers)
                    first_span = line["spans"][0]
                    blocks.append(TextBlock(
                        text=line_text.strip(),
                        bbox=tuple(line["bbox"]),
                        page_num=page_num,
                        font_size=first_span["size"],
                        font_name=first_span["font"],
                        is_bold="bold" in first_span["font"].lower(),
                        block_type="text"
                    ))
            elif block["type"] == 1:  # image block - could be a table rendered as image
                blocks.append(TextBlock(
                    text="[IMAGE]",
                    bbox=tuple(block["bbox"]),
                    page_num=page_num,
                    font_size=0,
                    font_name="",
                    is_bold=False,
                    block_type="image"
                ))
    
    doc.close()
    return blocks

Verify: run on a sample discharge summary. You should see blocks with increasing y-coordinates per page, font sizes clustering around 10-12pt for body text and 14-18pt for headers.

Step 2: Detect clinical section headers

Clinical documents follow loose but recognizable patterns. Section headers are typically bold, larger font, often all-caps or title case, and appear at left margin. We build a heuristic classifier tuned for common clinical sections.

# section_detector.py
import re
from typing import List, Dict, Optional
from pdf_parser import TextBlock

CLINICAL_SECTIONS = {
    # History & physical
    "chief_complaint": ["chief complaint", "cc:", "presenting complaint"],
    "hpi": ["history of present illness", "hpi:", "present illness"],
    "pmh": ["past medical history", "pmh:", "medical history"],
    "psh": ["past surgical history", "psh:", "surgical history"],
    "medications": ["medications", "current medications", "meds:", "home medications"],
    "allergies": ["allergies", "drug allergies", "allergy:"],
    "family_history": ["family history", "fh:", "family hx"],
    "social_history": ["social history", "sh:", "social hx"],
    "review_of_systems": ["review of systems", "ros:", "systems review"],
    "physical_exam": ["physical exam", "physical examination", "pe:", "exam:"],
    "vitals": ["vital signs", "vitals:", "vs:"],
    "assessment": ["assessment", "impression:", "diagnosis:"],
    "plan": ["plan", "treatment plan", "disposition:", "follow-up:"],
    # Labs / radiology
    "results": ["results", "findings:", "result:"],
    "impression": ["impression", "conclusion:"],
    "recommendations": ["recommendations", "recommended:"],
}

HEADER_PATTERNS = [
    re.compile(r"^[A-Z][A-Z\s]{2,}:?$"),  # ALL CAPS header
    re.compile(r"^[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*:?$"),  # Title Case
]

def is_likely_header(block: TextBlock, prev_block: Optional[TextBlock] = None) -> bool:
    text = block.text.strip()
    if len(text) > 80:  # headers are short
        return False
    if not (block.is_bold or block.font_size >= 13):
        return False
    if block.bbox[0] > 100:  # not left-aligned (adjust for your PDF margins)
        return False
    
    # Match known clinical sections
    text_lower = text.lower().rstrip(":")
    for section_key, aliases in CLINICAL_SECTIONS.items():
        if any(alias in text_lower for alias in aliases):
            return True
    
    # Fallback: pattern match
    return any(p.match(text) for p in HEADER_PATTERNS)

def classify_section(header_text: str) -> str:
    text_lower = header_text.lower().rstrip(":")
    for section_key, aliases in CLINICAL_SECTIONS.items():
        if any(alias in text_lower for alias in aliases):
            return section_key
    return "other"

def segment_blocks(blocks: List[TextBlock]) -> List[Dict]:
    """Group blocks into sections. Returns list of {section, blocks, page_start, page_end}."""
    segments = []
    current_section = "preamble"
    current_blocks = []
    section_start_page = blocks[0].page_num if blocks else 0
    
    for i, block in enumerate(blocks):
        prev = blocks[i-1] if i > 0 else None
        
        if is_likely_header(block, prev):
            # Save previous section
            if current_blocks:
                segments.append({
                    "section": current_section,
                    "blocks": current_blocks,
                    "page_start": section_start_page,
                    "page_end": prev.page_num if prev else section_start_page
                })
            # Start new section
            current_section = classify_section(block.text)
            current_blocks = [block]
            section_start_page = block.page_num
        else:
            current_blocks.append(block)
    
    # Final section
    if current_blocks:
        segments.append({
            "section": current_section,
            "blocks": current_blocks,
            "page_start": section_start_page,
            "page_end": blocks[-1].page_num if blocks else 0
        })
    
    return segments

Verify: print segments for a known PDF. You should see section keys like “hpi”, “medications”, “assessment” with block counts that match visual inspection.

Step 3: Extract and serialize tables

Clinical PDFs contain medication tables, lab result grids, vital sign tables. These must stay intact. pdfplumber extracts tables with cell alignment; we serialize each table to markdown for embedding compatibility.

# table_extractor.py
import pdfplumber
from typing import List, Dict, Optional
from pdf_parser import TextBlock

def extract_tables(pdf_path: str) -> List[Dict]:
    """Returns list of {page_num, bbox, markdown, raw_rows}."""
    tables = []
    with pdfplumber.open(pdf_path) as pdf:
        for page_num, page in enumerate(pdf.pages):
            page_tables = page.extract_tables({
                "vertical_strategy": "lines",
                "horizontal_strategy": "lines",
                "intersection_tolerance": 5,
                "snap_tolerance": 3,
            })
            
            for table_idx, table in enumerate(page_tables):
                if not table or len(table) < 2:
                    continue
                
                # Clean cells
                cleaned = [[cell.strip() if cell else "" for cell in row] for row in table]
                
                # Skip empty tables
                if not any(any(cell for cell in row) for row in cleaned):
                    continue
                
                # Convert to markdown
                markdown = table_to_markdown(cleaned)
                
                tables.append({
                    "page_num": page_num,
                    "table_index": table_idx,
                    "markdown": markdown,
                    "raw_rows": cleaned,
                    "bbox": None  # pdfplumber doesn't easily give bbox; approximate from page
                })
    return tables

def table_to_markdown(rows: List[List[str]]) -> str:
    if not rows:
        return ""
    
    # Assume first row is header
    header = rows[0]
    body = rows[1:]
    
    # Escape pipes in cells
    def escape(cell):
        return cell.replace("|", "\\|")
    
    header_row = "| " + " | ".join(escape(c) for c in header) + " |"
    separator = "| " + " | ".join(["---"] * len(header)) + " |"
    body_rows = ["| " + " | ".join(escape(c) for c in row) + " |" for row in body]
    
    return "\n".join([header_row, separator] + body_rows)

def associate_tables_with_sections(tables: List[Dict], segments: List[Dict]) -> List[Dict]:
    """Attach each table to the nearest preceding section."""
    for table in tables:
        table_page = table["page_num"]
        # Find section that contains this page
        assigned = False
        for seg in segments:
            if seg["page_start"] <= table_page <= seg["page_end"]:
                if "tables" not in seg:
                    seg["tables"] = []
                seg["tables"].append(table)
                assigned = True
                break
        if not assigned:
            # Fallback: create synthetic section
            segments.append({
                "section": "table_only",
                "blocks": [],
                "tables": [table],
                "page_start": table_page,
                "page_end": table_page
            })
    return segments

Verify: check markdown output for a medication table. Columns should align, headers preserved, no cell truncation.

Step 4: Build semantic chunks with overlap control

Now we assemble chunks. Strategy: each clinical section becomes one or more chunks. If a section exceeds token budget, split at paragraph boundaries with 100-token overlap. Tables become their own chunks (they’re dense). Preserve section metadata for filtered retrieval.

# chunker.py
from dataclasses import dataclass, asdict
from typing import List, Dict, Optional
import tiktoken
from pdf_parser import TextBlock

@dataclass
class Chunk:
    text: str
    section: str
    page_start: int
    page_end: int
    token_count: int
    chunk_index: int
    total_chunks_in_section: int
    document_id: str
    metadata: Dict  # extensible: patient_id, encounter_date, doc_type, etc.

def count_tokens(text: str, model: str = "text-embedding-3-small") -> int:
    enc = tiktoken.encoding_for_model(model)
    return len(enc.encode(text))

def split_long_section(text: str, max_tokens: int, overlap_tokens: int, model: str) -> List[str]:
    """Split at paragraph boundaries with token overlap."""
    enc = tiktoken.encoding_for_model(model)
    paragraphs = text.split("\n\n")
    
    chunks = []
    current_chunk_paras = []
    current_tokens = 0
    
    for para in paragraphs:
        para_tokens = len(enc.encode(para))
        
        if current_tokens + para_tokens > max_tokens and current_chunk_paras:
            # Emit current chunk
            chunks.append("\n\n".join(current_chunk_paras))
            
            # Start new chunk with overlap from tail of previous
            overlap_text = "\n\n".join(current_chunk_paras)
            overlap_token_ids = enc.encode(overlap_text)[-overlap_tokens:]
            overlap_str = enc.decode(overlap_token_ids)
            
            current_chunk_paras = [overlap_str, para]
            current_tokens = len(enc.encode(overlap_str)) + para_tokens
        else:
            current_chunk_paras.append(para)
            current_tokens += para_tokens
    
    if current_chunk_paras:
        chunks.append("\n\n".join(current_chunk_paras))
    
    return chunks

def build_chunks(
    segments: List[Dict],
    document_id: str,
    max_tokens: int = 512,
    overlap_tokens: int = 100,
    model: str = "text-embedding-3-small"
) -> List[Chunk]:
    chunks = []
    
    for seg in segments:
        section = seg["section"]
        page_start = seg["page_start"]
        page_end = seg["page_end"]
        
        # Combine text blocks
        section_text = "\n".join(b.text for b in seg["blocks"] if b.block_type == "text")
        
        # Handle tables: each table becomes its own chunk
        tables = seg.get("tables", [])
        
        # First, chunk the section text
        if section_text.strip():
            text_chunks = split_long_section(section_text, max_tokens, overlap_tokens, model)
            
            for idx, chunk_text in enumerate(text_chunks):
                chunks.append(Chunk(
                    text=chunk_text,
                    section=section,
                    page_start=page_start,
                    page_end=page_end,
                    token_count=count_tokens(chunk_text, model),
                    chunk_index=idx,
                    total_chunks_in_section=len(text_chunks),
                    document_id=document_id,
                    metadata={
                        "has_tables": len(tables) > 0,
                        "section": section,
                    }
                ))
        
        # Then, table chunks
        for table_idx, table in enumerate(tables):
            table_text = f"[TABLE: {section}]\n{table['markdown']}"
            chunks.append(Chunk(
                text=table_text,
                section=f"{section}_table",
                page_start=table["page_num"],
                page_end=table["page_num"],
                token_count=count_tokens(table_text, model),
                chunk_index=table_idx,
                total_chunks_in_section=len(tables),
                document_id=document_id,
                metadata={
                    "is_table": True,
                    "table_index": table_idx,
                    "raw_rows": table["raw_rows"],
                }
            ))
    
    return chunks

Verify: token counts should cluster near max_tokens (512) with few outliers. Table chunks may exceed budget — that’s acceptable; they’re atomic.

Step 5: Enrich with clinical metadata

Retrieval quality improves dramatically when you can filter by patient, encounter, document type, or section. Extract what you can from the PDF text and filename conventions.

# metadata_enricher.py
import re
from datetime import datetime
from typing import Dict, Optional
from chunker import Chunk

# Common patterns in clinical filenames: MRN_encounterDate_docType.pdf
FILENAME_PATTERN = re.compile(
    r"(?P<mrn>\d{6,10})_?(?P<encounter_date>\d{8})_?(?P<doc_type>[a-zA-Z]+)\.pdf$",
    re.IGNORECASE
)

DOC_TYPE_MAP = {
    "ds": "discharge_summary",
    "h&p": "history_physical",
    "hp": "history_physical",
    "op": "operative_note",
    "consult": "consultation",
    "rad": "radiology",
    "lab": "laboratory",
    "path": "pathology",
    "ed": "ed_note",
    "progress": "progress_note",
}

SECTION_WEIGHTS = {
    "chief_complaint": 1.5,
    "hpi": 1.3,
    "assessment": 1.4,
    "plan": 1.3,
    "medications": 1.2,
    "allergies": 1.5,
    "impression": 1.4,
    "results": 1.1,
    "table_only": 0.8,
}

def extract_metadata_from_filename(filename: str) -> Dict:
    match = FILENAME_PATTERN.search(filename)
    if not match:
        return {}
    
    mrn = match.group("mrn")
    date_str = match.group("encounter_date")
    doc_type_raw = match.group("doc_type").lower()
    
    try:
        encounter_date = datetime.strptime(date_str, "%Y%m%d").date().isoformat()
    except ValueError:
        encounter_date = None
    
    doc_type = DOC_TYPE_MAP.get(doc_type_raw, doc_type_raw)
    
    return {
        "mrn": mrn,
        "encounter_date": encounter_date,
        "document_type": doc_type,
    }

def extract_patient_demographics(text: str) -> Dict:
    """Best-effort extraction from first few pages."""
    meta = {}
    
    # DOB pattern
    dob_match = re.search(r"(?:DOB|Date of Birth|Born)[:\s]+(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})", text, re.IGNORECASE)
    if dob_match:
        meta["patient_dob"] = dob_match.group(1)
    
    # Sex/Gender
    sex_match = re.search(r"\b(?:Sex|Gender)[:\s]+(Male|Female|M|F)\b", text, re.IGNORECASE)
    if sex_match:
        meta["patient_sex"] = sex_match.group(1)[0].upper()
    
    return meta

def enrich_chunks(chunks: List[Chunk], filename: str, full_text: str) -> List[Chunk]:
    file_meta = extract_metadata_from_filename(filename)
    demo_meta = extract_patient_demographics(full_text[:5000])  # first 5k chars
    
    for chunk in chunks:
        # Merge metadata
        chunk.metadata.update(file_meta)
        chunk.metadata.update(demo_meta)
        
        # Add section weight for retrieval boosting
        chunk.metadata["section_weight"] = SECTION_WEIGHTS.get(chunk.section, 1.0)
        
        # Document-level ID for deduplication
        chunk.metadata["document_id"] = chunk.document_id
    
    return chunks

Verify: inspect chunk.metadata for a known PDF. You should see mrn, encounter_date, document_type, section_weight populated.

Step 6: Embed and index with section-aware retrieval

Embed chunks. At query time, boost by section_weight and filter by metadata. This is where n4n.ai’s routing directives help — you can send clinical queries to a model tuned for medical reasoning while keeping the same embedding index.

# embed_and_index.py
import json
from typing import List
from chunker import Chunk
from openai import OpenAI

client = OpenAI()  # or your preferred embedding provider

EMBED_MODEL = "text-embedding-3-small"
BATCH_SIZE = 100

def embed_chunks(chunks: List[Chunk]) -> List[Dict]:
    """Returns list of {chunk, embedding} dicts."""
    records = []
    
    for i in range(0, len(chunks), BATCH_SIZE):
        batch = chunks[i:i+BATCH_SIZE]
        texts = [c.text for c in batch]
        
        response = client.embeddings.create(
            model=EMBED_MODEL,
            input=texts,
            encoding_format="float"
        )
        
        for chunk, emb_data in zip(batch, response.data):
            records.append({
                "id": f"{chunk.document_id}_chunk_{chunk.chunk_index}",
                "text": chunk.text,
                "embedding": emb_data.embedding,
                "metadata": chunk.metadata
            })
    
    return records

def save_index(records: List[Dict], output_path: str):
    with open(output_path, "w") as f:
        for r in records:
            f.write(json.dumps(r) + "\n")

# Example query-time filter construction
def build_retrieval_filter(
    mrn: Optional[str] = None,
    encounter_date: Optional[str] = None,
    document_type: Optional[str] = None,
    sections: Optional[List[str]] = None
) -> Dict:
    """Build a metadata filter for your vector DB (Pinecone, Weaviate, Qdrant, etc.)."""
    filter_dict = {}
    if mrn:
        filter_dict["mrn"] = mrn
    if encounter_date:
        filter_dict["encounter_date"] = encounter_date
    if document_type:
        filter_dict["document_type"] = document_type
    if sections:
        filter_dict["section"] = {"$in": sections}
    return filter_dict

Verify: load the JSONL, spot-check embeddings dimension (1536 for text-embedding-3-small), confirm metadata fields present.

Step 7: End-to-end pipeline script

Wire it together. This runs in ~30 seconds for a 50-page PDF on a modern laptop.

# pipeline.py
import sys
import os
from pdf_parser import extract_blocks
from section_detector import segment_blocks
from table_extractor import extract_tables, associate_tables_with_sections
from chunker import build_chunks
from metadata_enricher import enrich_chunks
from embed_and_index import embed_chunks, save_index

def process_pdf(pdf_path: str, output_path: str):
    document_id = os.path.splitext(os.path.basename(pdf_path))[0]
    
    print(f"[{document_id}] Extracting blocks...")
    blocks = extract_blocks(pdf_path)
    
    print(f"[{document_id}] Detecting sections...")
    segments = segment_blocks(blocks)
    
    print(f"[{document_id}] Extracting tables...")
    tables = extract_tables(pdf_path)
    segments = associate_tables_with_sections(tables, segments)
    
    print(f"[{document_id}] Building chunks...")
    chunks = build_chunks(segments, document_id)
    
    # Full text for demographic extraction
    full_text = "\n".join(b.text for b in blocks)
    chunks = enrich_chunks(chunks, os.path.basename(pdf_path), full_text)
    
    print(f"[{document_id}] Embedding {len(chunks)} chunks...")
    records = embed_chunks(chunks)
    
    print(f"[{document_id}] Saving index to {output_path}...")
    save_index(records, output_path)
    
    # Stats
    sections_seen = set(c.metadata.get("section") for c in chunks)
    table_chunks = sum(1 for c in chunks if c.metadata.get("is_table"))
    print(f"[{document_id}] Done. Sections: {sections_seen}, Table chunks: {table_chunks}, Total: {len(chunks)}")

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("Usage: python pipeline.py <input.pdf> <output.jsonl>")
        sys.exit(1)
    
    process_pdf(sys.argv[1], sys.argv[2])

Run it:

python pipeline.py "data/123456_20240115_ds.pdf" "index/123456_20240115_ds.jsonl"

Verify: output JSONL loads, each record has id, text, embedding (1536 floats), metadata with mrn, encounter_date, document_type, section, section_weight. Spot-check a medication table chunk — markdown renders cleanly.

Common failure modes and fixes

Symptom Cause Fix
Sections merge incorrectly Headers not bold or font size varies Lower font_size threshold in is_likely_header; add regex for numbered sections (1., 2.)
Tables split across pages pdfplumber misses continuation Post-process: if table on page N ends mid-row and page N+1 starts with similar columns, merge
Token counts exceed budget Long unbroken text (e.g., narrative HPI) Ensure split_long_section handles single-paragraph overflow by hard-splitting at sentence boundary
Metadata missing Filename convention not followed Add fallback: OCR first page for MRN/date; or accept manual override CSV
Embedding latency high Large batch size OOM Reduce BATCH_SIZE to 50; use async client

What this buys you

  • Section-filtered retrieval: Query “current medications” → filter section=medications → precise hits.
  • Table integrity: Lab values, med doses stay in structured markdown, not fragmented across chunks.
  • Patient-scoped search: Filter by MRN + encounter date before vector search — reduces noise, respects privacy boundaries.
  • Re-ranking signal: section_weight lets you boost assessment/plan over review-of-systems at query time.

The pipeline is modular. Swap fitz for pdfplumber, OpenAI embeddings for local sentence-transformers, JSONL for your vector DB’s bulk loader. The chunking logic — section-aware, table-preserving, metadata-rich — stays the same.

Tagshealthcarechunkingpdf-parsingrag

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All framework tutorials: legal & healthcare document q&a posts →