Keep Page Coordinates with Your RAG Chunks

November 11, 2026

Keep Page Coordinates with Your RAG Chunks

Every chunk in your vector store can remember exactly where it came from in the PDF. PyMuPDF already gives you the coordinates; this article shows how to keep them.

A RAG pipeline splits documents into chunks, embeds them and retrieves the best matches for each question. When the model answers, the chunk is the evidence. But most pipelines keep only the file name and perhaps a page number, so when a user asks "where does it say that?", the application cannot point to the spot.

PyMuPDF already knows. When it converts a PDF to Markdown, it records where on the page each part of the Markdown came from. In this article we keep that information with every chunk, store it in LangChain and LlamaIndex, and use it to show the source region of any retrieved chunk.

What PyMuPDF already gives you

With page_chunks=True, Document.to_markdown() returns one dictionary per page. Besides the page's Markdown text, each dictionary has page_boxes: the layout boxes on the page in reading order. Every box has a class (such as text, section-header or table), a bbox on the page, and a pos range that says which part of the Markdown the box produced.

import pymupdf

doc = pymupdf.open("acme_report.pdf")
for page_dict in doc.to_markdown(page_chunks=True):
    text = page_dict["text"]
    for box in page_dict["page_boxes"]:
        start, stop = box["pos"]
        print(page_dict["metadata"]["page_number"], box["class"], box["bbox"], repr(text[start:stop][:45]))
1 section-header (72, 76, 404, 94) '# **Acme Corporation Annual Report 2025** \n\n'
1 section-header (72, 107, 220, 122) '## **1. Financial highlights** \n\n'
1 text (72, 135, 400, 212) 'Net revenue increased 12% to $4.1 billion, dr'
1 section-header (72, 232, 141, 244) '## **2. Outlook** \n\n'
1 text (72, 260, 395, 318) 'Management expects revenue growth of 8% to 10'

So text[start:stop] is the Markdown from one box, and bbox is where that box sits on the page: (x0, y0, x1, y1) in points (72 per inch), measured from the top-left corner of the page and rounded to whole points. Layout analysis is on by default in PyMuPDF 2.x, so there is nothing extra to install.

Step 1: Build chunks that remember where they came from

Instead of splitting the Markdown text with a generic splitter, which throws the positions away, build chunks from the boxes. This version starts a new chunk at each section header, so chunks follow the document's structure, and also starts one when a chunk grows past a size limit. Each chunk records the page and bbox of every box it contains, so chunks can span pages.

import json
import pymupdf

def chunks_with_coordinates(path, max_chars=1500):
    """Split a PDF into Markdown chunks that remember where each part came from.

    A new chunk starts at each section header, or when a chunk would exceed max_chars.
    Each chunk records the page number and bbox of every layout box it contains.
    """
    doc = pymupdf.open(path)
    chunks, current = [], None
    for page_dict in doc.to_markdown(page_chunks=True):
        page_number = page_dict["metadata"]["page_number"]      # 1-based
        text = page_dict["text"]
        for box in page_dict["page_boxes"]:
            start, stop = box["pos"]
            box_text = text[start:stop]
            is_header = box["class"] in ("title", "section-header")
            new_section = is_header and current and current["has_body"]
            too_long = current and len(current["text"]) + len(box_text) > max_chars
            if current is None or new_section or too_long:
                current = {"text": "", "boxes": [], "has_body": False}
                chunks.append(current)
            current["text"] += box_text
            current["has_body"] |= not is_header
            current["boxes"].append({"page": page_number, "bbox": list(box["bbox"])})
    return chunks

Step 2: Store the coordinates as metadata

Most vector stores accept only flat metadata: strings, numbers and booleans. So keep the first page as a number, for filtering, and store the full list of boxes as a JSON string:

def chunk_metadata(path, chunk):
    """Flat metadata that vector stores accept: strings and numbers only."""
    return {
        "source": path,
        "page": chunk["boxes"][0]["page"],                 # first page, for filtering
        "boxes": json.dumps(chunk["boxes"]),               # every page and bbox
    }

Output:

{
  "source": "acme_report.pdf",
  "page": 1,
  "boxes": "[{\"page\": 1, \"bbox\": [72, 232, 141, 244]}, {\"page\": 1, \"bbox\": [72, 260, 395, 318]}]"
}

Step 3: Load the chunks into LangChain or LlamaIndex

In LangChain, each chunk becomes a Document:

from langchain_core.documents import Document

path = "acme_report.pdf"
documents = [
    Document(page_content=c["text"], metadata=chunk_metadata(path, c))
    for c in chunks_with_coordinates(path)
]

In LlamaIndex, each chunk becomes a TextNode. Exclude boxes from the text that is embedded and sent to the model, since coordinates mean nothing to either:

from llama_index.core.schema import TextNode

nodes = [
    TextNode(
        text=c["text"],
        metadata=chunk_metadata(path, c),
        excluded_embed_metadata_keys=["boxes"],   # keep coordinates out of the embedding
        excluded_llm_metadata_keys=["boxes"],     # and out of the prompt
    )
    for c in chunks_with_coordinates(path)
]

From here, index the documents or nodes in any vector store as usual. The coordinates travel with every chunk.

Step 4: Show where a retrieved chunk came from

When the retriever returns a chunk, its metadata tells you exactly which page regions to show. This function renders one image per page the chunk touches:

def chunk_images(metadata, dpi=150, margin=12):
    """Render the page regions a chunk came from: one image per page it touches."""
    doc = pymupdf.open(metadata["source"])
    regions = {}
    for b in json.loads(metadata["boxes"]):
        regions.setdefault(b["page"], pymupdf.Rect())
        regions[b["page"]] |= pymupdf.Rect(b["bbox"])
    images = []
    for page_number, rect in regions.items():
        page = doc[page_number - 1]
        clip = (rect + (-margin, -margin, margin, margin)) & page.rect
        images.append(page.get_pixmap(clip=clip, dpi=dpi))
    return images
hit = retriever.invoke("How much did revenue grow?")[0]     # any retriever
for i, pix in enumerate(chunk_images(hit.metadata)):
    pix.save(f"source_{i}.png")
The source region of the "Financial highlights" chunk, rendered from its stored coordinates

Show these images next to the answer, or send the page numbers and boxes to your front end and draw them over the page there.

Going further: from chunk to exact sentence

A chunk is often a whole section, while the model's answer usually rests on one sentence. Search for the quoted sentence only inside the chunk's regions to keep the search to the part of the page the chunk came from, so it cannot match the same sentence elsewhere in the document.

import json

def locate_in_chunk(doc, metadata, quote):
    """Find a quote inside a retrieved chunk's regions only."""
    for b in json.loads(metadata["boxes"]):
        page = doc[b["page"] - 1]
        quads = page.search_for(quote, clip=pymupdf.Rect(b["bbox"]), quads=True)
        if quads:
            return b["page"], quads
    return None, []

Limits to know

  • Coordinates are per layout box: a paragraph, heading, list item or table. For sentence-level positions, use the search shown above.
  • A single box longer than the size limit stays in one chunk, because the boxes are never split.
  • Scanned pages need OCR before they have text and boxes.
  • The PyMuPDF loaders for LangChain and LlamaIndex do not pass page_boxes through today, so build the chunks yourself as shown here.

Why this matters

Coordinates stored at indexing time cost almost nothing, and they turn every retrieved chunk into evidence a reader can check. They also give you an audit trail: for any answer, you can say which document, which page and which region supported it, long after the answer was produced.

Next steps


Discuss This Article with the Community

Have a question, a different approach, or something you built after reading this? Share it on the forum or join the Discord, we'd love to hear from you.

PyMuPDF Logo

© 2026 Artifex Software Inc. All rights reserved.