Word documents are one of the most common inputs to document-ingestion and RAG pipelines. This page explains what a .docx file actually contains, walks through the processor script below, gives a working Docling and python-docx alternative, and covers how to turn the output into retrieval-ready chunks.
import os
from pathlib import Path
from docling.document_converter import DocumentConverter
from docling_core.types.doc import DocItemLabel
def process_word_document(input_file: str, output_dir: str = "output") -> None:
"""
Process a Word document and extract its content and structure.
Uses Docling 2.x: DocumentConverter returns a ConversionResult whose
.document is a DoclingDocument (texts, tables, export_to_* methods).
Args:
input_file (str): Path to the input Word document (.docx)
output_dir (str): Directory to save processed outputs
"""
os.makedirs(output_dir, exist_ok=True)
in_path = Path(input_file)
if not in_path.exists():
raise FileNotFoundError(f"Input file not found: {input_file}")
try:
# DocumentConverter picks the Word backend from the .docx extension
result = DocumentConverter().convert(in_path)
doc = result.document
# Save the content: Markdown keeps headings, lists and tables
md_output = Path(output_dir) / f"{in_path.stem}_content.md"
md_output.write_text(doc.export_to_markdown(), encoding="utf-8")
# Document structure, counted from the labelled text items
headings = [t for t in doc.texts
if t.label in (DocItemLabel.TITLE, DocItemLabel.SECTION_HEADER)]
paragraphs = [t for t in doc.texts
if t.label in (DocItemLabel.PARAGRAPH, DocItemLabel.TEXT)]
title = next((t.text for t in doc.texts if t.label == DocItemLabel.TITLE),
headings[0].text if headings else doc.name)
print("\nDocument Structure:")
print("-" * 20)
print(f"Title: {title}")
print(f"Sections: {len(headings)}")
print(f"Paragraphs: {len(paragraphs)}")
# Tables: one CSV per table, plus a Markdown rendering of each
if doc.tables:
print(f"\nTables found: {len(doc.tables)}")
tables_output = Path(output_dir) / f"{in_path.stem}_tables.md"
with open(tables_output, "w", encoding="utf-8") as f:
for i, table in enumerate(doc.tables, 1):
df = table.export_to_dataframe(doc=doc)
df.to_csv(Path(output_dir) / f"{in_path.stem}_table_{i}.csv", index=False)
f.write(f"\nTable {i}:\n")
f.write(table.export_to_markdown(doc=doc))
f.write("\n" + "-" * 50 + "\n")
print(f"\nProcessing complete! Outputs saved in: {output_dir}")
except Exception as e:
print(f"Error processing document: {e}")
def main():
# Example usage
sample_doc = "word_sample.docx"
process_word_document(sample_doc)
if __name__ == "__main__":
main()
A .docx file is a ZIP archive in the Office Open XML format. The body lives in word/document.xml as a sequence of paragraphs (w:p), each made of runs (w:r) that share formatting, plus tables (w:tbl). Headings are not a separate element; they are ordinary paragraphs whose style is Heading 1, Heading 2 and so on, defined in styles.xml. Images sit under word/media/ and are referenced by relationship ids. Unlike a PDF, a .docx carries real logical structure, so extraction is far more reliable: there is no need for OCR or layout analysis to recover reading order.
The script on this page follows the first half of this pipeline: it validates the input path, creates an output directory, parses the document with Docling's Word backend and writes text and tables to files. For RAG, the important step is keeping the structure. Exporting to Markdown preserves headings and tables, which lets the chunker split on section boundaries and attach the heading path (for example Contract > Termination > Notice period) to every chunk as metadata.
The script above uses the Docling 2.x API. An earlier version of this page built an InputDocument by hand, called MsWordDocumentBackend.convert(), and then read doc.title, doc.sections, doc.paragraphs and doc.get_text(). None of those exist on the DoclingDocument that current releases return, so that version failed with an AttributeError. The fixed script goes through DocumentConverter, the stable public entry point, and derives the structure from the document model itself: every text item carries a label (TITLE, SECTION_HEADER, PARAGRAPH, TEXT, LIST_ITEM, ...), so headings and paragraphs are counted by label; the title falls back to the first heading and then to doc.name (the file stem). Content is written as Markdown, which keeps the heading hierarchy for chunking, and each table is exported both as Markdown and as a CSV through export_to_dataframe(). The shorter function below does just the Markdown export:
from pathlib import Path
from docling.document_converter import DocumentConverter
def docx_to_markdown(path: str, out_dir: str = "output") -> Path:
result = DocumentConverter().convert(path) # detects DOCX by extension
doc = result.document
out = Path(out_dir); out.mkdir(exist_ok=True)
md_path = out / (Path(path).stem + ".md")
md_path.write_text(doc.export_to_markdown(), encoding="utf-8")
for i, table in enumerate(doc.tables, 1):
df = table.export_to_dataframe()
df.to_csv(out / f"{Path(path).stem}_table{i}.csv", index=False)
return md_path
print(docx_to_markdown("word_sample.docx"))For simple jobs without the Docling dependency, python-docx reads paragraphs, styles and tables directly:
from docx import Document # pip install python-docx
doc = Document("word_sample.docx")
for p in doc.paragraphs:
if p.style.name.startswith("Heading"):
level = p.style.name.split()[-1]
print("#" * int(level) if level.isdigit() else "#", p.text)
elif p.text.strip():
print(p.text)
for t in doc.tables:
for row in t.rows:
print(" | ".join(c.text for c in row.cells))Note that iterating doc.paragraphs and doc.tables separately loses their relative order; Docling keeps body order, which matters when a table belongs to a specific section.
python -m venv .venv && source .venv/bin/activate
pip install docling python-docx
python word_processor.py # writes output/word_sample_content.txt
ls output/| Tool | Strength | Limitation |
|---|---|---|
| python-docx | Lightweight, full access to styles and runs | No reading-order merge, no .doc support |
| Docling | Unified model for DOCX, PDF, PPTX; Markdown/JSON export | Heavier install |
| Pandoc | Fast DOCX to Markdown CLI | Less control from Python |
| LibreOffice headless | Converts legacy .doc to .docx | External process |
When processing many documents, run the converter once per worker process and reuse it, since model and backend initialisation dominate the cost for small files. Record a content hash of each input so unchanged documents are skipped on the next run, and store the extracted Markdown alongside the source path and modification time so downstream chunks can always be traced back to the exact file and version they came from.
.doc files are not OOXML; convert them first with soffice --headless --convert-to docx.