Office Documents for RAG
Format Matrix
| Format |
Library |
Markdown Path |
Tables |
Images |
Comments |
| DOCX |
python-docx |
mammoth |
Yes |
python-docx InlineShape |
python-docx |
| PPTX |
python-pptx |
custom |
Limited |
shape.image.blob |
slide.notes_slide |
| XLSX |
openpyxl / pandas |
custom |
Native |
Embedded images |
openpyxl |
| Notion |
Notion API + notion-to-md |
Native |
Yes |
Block downloads |
Discussion blocks |
| Confluence |
atlassian-python-api |
html2md |
Yes |
Attachments endpoint |
Inline comments API |
| Quip |
Quip API |
html2md |
Yes |
Blob endpoint |
Section comments |
DOCX — python-docx + mammoth
# Structured extraction with python-docx
from docx import Document
from docx.oxml.ns import qn
def extract_docx(path: str) -> list[dict]:
doc = Document(path)
out = []
heading_stack: list[str] = []
for para in doc.paragraphs:
style = (para.style.name or "").lower()
text = para.text.strip()
if not text:
continue
if style.startswith("heading"):
try:
level = int(style.split()[-1])
except ValueError:
level = 1
heading_stack = heading_stack[: level - 1] + [text]
out.append({"type": "heading", "level": level, "text": text,
"path": list(heading_stack)})
elif style == "list paragraph":
out.append({"type": "list_item", "text": text,
"path": list(heading_stack)})
else:
out.append({"type": "paragraph", "text": text,
"path": list(heading_stack)})
# Tables
for t_idx, table in enumerate(doc.tables):
rows = [[cell.text.strip() for cell in row.cells] for row in table.rows]
out.append({"type": "table", "index": t_idx, "rows": rows,
"path": list(heading_stack)})
return out
DOCX to Markdown with Mammoth
import mammoth
with open("brief.docx", "rb") as f:
result = mammoth.convert_to_markdown(
f,
style_map="""
p[style-name='Heading 1'] => h1:fresh
p[style-name='Heading 2'] => h2:fresh
p[style-name='Quote'] => blockquote:fresh
r[style-name='Code'] => code
""",
convert_image=mammoth.images.img_element(lambda img: {
"src": save_image(img),
}),
)
markdown = result.value
for message in result.messages:
print("mammoth:", message.type, message.message)
Comments and Revisions
from docx.oxml.ns import qn
def extract_comments(path: str) -> list[dict]:
doc = Document(path)
comments_part = doc.part.package.part_related_by_reltype(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
)
xml = comments_part.blob
from lxml import etree
tree = etree.fromstring(xml)
out = []
for c in tree.findall(qn("w:comment")):
out.append({
"id": c.get(qn("w:id")),
"author": c.get(qn("w:author")),
"date": c.get(qn("w:date")),
"text": " ".join(t.text or "" for t in c.iter(qn("w:t"))),
})
return out
# Tracked changes: look for w:ins (insertion) and w:del (deletion) elements
Embedded Images from DOCX
from pathlib import Path
def extract_docx_images(path: str, out_dir: str):
doc = Document(path)
Path(out_dir).mkdir(parents=True, exist_ok=True)
for rel in doc.part.rels.values():
if "image" in rel.reltype:
blob = rel.target_part.blob
ext = rel.target_part.partname.split(".")[-1]
(Path(out_dir) / f"{rel.rId}.{ext}").write_bytes(blob)
PPTX — Slide to Markdown
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pathlib import Path
def pptx_to_markdown(path: str, image_dir: str) -> list[dict]:
Path(image_dir).mkdir(parents=True, exist_ok=True)
prs = Presentation(path)
slides = []
for i, slide in enumerate(prs.slides, start=1):
parts: list[str] = []
title = ""
for shape in slide.shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
txt = " ".join(run.text for run in para.runs).strip()
if not txt:
continue
if shape == slide.shapes.title and not title:
title = txt
parts.append(f"# {txt}")
else:
bullet = "- " if para.level > 0 else ""
parts.append(f"{' ' * para.level}{bullet}{txt}")
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
img = shape.image
name = f"slide{i}_{shape.shape_id}.{img.ext}"
(Path(image_dir) / name).write_bytes(img.blob)
parts.append(f"")
notes = ""
if slide.has_notes_slide:
notes = slide.notes_slide.notes_text_frame.text.strip()
slides.append({
"slide": i,
"title": title,
"markdown": "\n\n".join(parts),
"notes": notes,
})
return slides
XLSX — Table-Aware Chunking
import openpyxl
import pandas as pd
from dataclasses import dataclass
@dataclass
class SheetChunk:
sheet: str
range: str
markdown: str
row_start: int
row_end: int
def xlsx_to_chunks(path: str, rows_per_chunk: int = 50) -> list[SheetChunk]:
wb = openpyxl.load_workbook(path, data_only=True, read_only=True)
out: list[SheetChunk] = []
for sheet_name in wb.sheetnames:
df = pd.read_excel(path, sheet_name=sheet_name, header=0)
if df.empty:
continue
# Split by logical blocks of rows, keep header in each chunk
for start in range(0, len(df), rows_per_chunk):
end = min(start + rows_per_chunk, len(df))
slice_df = df.iloc[start:end]
md = slice_df.to_markdown(index=False)
out.append(SheetChunk(
sheet=sheet_name,
range=f"{sheet_name}!A{start+2}:{end+1}",
markdown=md,
row_start=start + 2,
row_end=end + 1,
))
return out
Cell Comments + Embedded Images
import openpyxl
wb = openpyxl.load_workbook("data.xlsx")
for ws in wb.worksheets:
for row in ws.iter_rows():
for cell in row:
if cell.comment:
print(ws.title, cell.coordinate,
cell.comment.author, cell.comment.text)
for img in ws._images: # openpyxl internal list
print(ws.title, img.anchor._from.col, img.anchor._from.row, img.ref)
Preserve Formulas and Formats
wb_formulas = openpyxl.load_workbook("data.xlsx", data_only=False)
wb_values = openpyxl.load_workbook("data.xlsx", data_only=True)
for fsheet, vsheet in zip(wb_formulas.worksheets, wb_values.worksheets):
for fcell, vcell in zip(fsheet["A1:D10"], vsheet["A1:D10"]):
for fc, vc in zip(fcell, vcell):
if fc.data_type == "f":
print(fc.coordinate, "formula:", fc.value, "value:", vc.value)
Notion Export to Markdown
import os
from notion_client import Client
from notion_to_md import NotionToMarkdown # notion-to-md-py
notion = Client(auth=os.environ["NOTION_TOKEN"])
n2m = NotionToMarkdown(notion_client=notion)
def export_page(page_id: str) -> str:
blocks = n2m.page_to_markdown(page_id)
return n2m.to_markdown_string(blocks)["parent"]
# Walk a database
db = notion.databases.query(database_id=os.environ["NOTION_DB"])
for page in db["results"]:
md = export_page(page["id"])
save_chunk(page["id"], md)
Confluence Export
from atlassian import Confluence
from markdownify import markdownify as html2md
c = Confluence(
url="https://yourorg.atlassian.net/wiki",
username=os.environ["CONFLUENCE_USER"],
password=os.environ["CONFLUENCE_TOKEN"],
cloud=True,
)
pages = c.get_all_pages_from_space("DOCS", start=0, limit=500, expand="body.storage,version")
for p in pages:
html = p["body"]["storage"]["value"]
md = html2md(html, heading_style="ATX", code_language_callback=lambda el: "python")
save_chunk(p["id"], f"# {p['title']}\n\n{md}")
Quip Export
import quip # pip install quip
client = quip.QuipClient(access_token=os.environ["QUIP_TOKEN"])
folder = client.get_folder(os.environ["QUIP_FOLDER_ID"])
for child in folder["children"]:
if "thread_id" in child:
thread = client.get_thread(child["thread_id"])
html = thread["html"]
md = html2md(html, heading_style="ATX")
save_chunk(thread["thread"]["id"], md)
RAG-Ready Chunking
from dataclasses import dataclass
@dataclass
class OfficeChunk:
text: str
source: str
format: str # docx | pptx | xlsx | notion | confluence
location: str # page/slide/cell-range/block-id
heading_path: list[str]
def docx_to_chunks(path: str, max_chars: int = 1200) -> list[OfficeChunk]:
items = extract_docx(path)
chunks: list[OfficeChunk] = []
buffer: list[str] = []
current_path: list[str] = []
for it in items:
if it["type"] == "heading":
if buffer:
chunks.append(OfficeChunk(
text="\n".join(buffer), source=path, format="docx",
location="paragraph", heading_path=current_path))
buffer = []
current_path = it["path"]
elif it["type"] == "table":
table_md = "\n".join("| " + " | ".join(r) + " |" for r in it["rows"])
chunks.append(OfficeChunk(text=table_md, source=path, format="docx",
location=f"table:{it['index']}",
heading_path=current_path))
else:
buffer.append(it["text"])
if sum(len(s) for s in buffer) > max_chars:
chunks.append(OfficeChunk(
text="\n".join(buffer), source=path, format="docx",
location="paragraph", heading_path=current_path))
buffer = []
if buffer:
chunks.append(OfficeChunk(text="\n".join(buffer), source=path,
format="docx", location="paragraph",
heading_path=current_path))
return chunks
Anti-Patterns
| Anti-Pattern |
Fix |
| Flattening DOCX to plain text |
Preserve heading hierarchy in metadata |
| Loading a 500MB XLSX into memory |
openpyxl.load_workbook(..., read_only=True) |
Ignoring data_only=True for XLSX |
Stored formula strings pollute embeddings |
| Chunking whole spreadsheets as one blob |
Split per sheet and per N rows with repeated header |
| Dropping slide speaker notes |
Notes often carry the real narrative |
| Scraping rendered HTML from Notion |
Use the API + notion-to-md for structure |
| Ignoring tracked changes |
Either accept them or extract as separate revision chunks |
| Skipping embedded images |
Extract and OCR when needed |
Production Checklist