How to Ingest a DOCX into LlamaIndex with EnConvert

Load DOCX files into LlamaIndex with heading-aware chunks and breadcrumb metadata. Filter by section, not just similarity. Free tier, 500 ops per month.

Get API key

Most developers reach for python-docx and iterate over document.paragraphs, which returns every paragraph in body order with no regard for what it is. Heading styles arrive as strings you have to inspect and reclassify, and the moment a document uses bold text as an informal header instead of a real heading style, that section boundary disappears from the extraction entirely. Tables and footnotes are stored in separate XML parts and silently dropped unless you handle them explicitly. When those flat paragraphs reach LlamaIndex, SentenceSplitter cuts on token count and produces chunks that start mid-sentence in one section and end mid-sentence in the next. There is no headings_path on the node because the hierarchy was never captured; retrieval can only rank by semantic similarity and cannot filter to the part of the document the user was actually asking about.

Example

python
import json
import os
import time

import requests
from llama_index.core import VectorStoreIndex
from llama_index.core.schema import TextNode
from llama_index.core.vector_stores import MetadataFilter, MetadataFilters

# pip install requests llama-index  (the full package, not llama-index-core alone).
# LlamaIndex's default embedding model reads OPENAI_API_KEY from the environment.
HEADERS = {"X-API-Key": os.environ["ENCONVERT_API_KEY"]}
DOCX_PATH = "/path/to/your/document.docx"

# Step 1: Upload the DOCX to EnConvert Ingest (multipart, field name "files")
with open(DOCX_PATH, "rb") as docx_file:
    response = requests.post(
        "https://api.enconvert.com/v2/ingest/files",
        headers=HEADERS,
        files={"files": (os.path.basename(DOCX_PATH), docx_file)},
        timeout=120,
    )
response.raise_for_status()
job_id = response.json()["job_id"]

# Step 2: Poll until the job reaches a final status (give up after 10 minutes)
deadline = time.monotonic() + 600
while True:
    if time.monotonic() > deadline:
        raise TimeoutError(f"Ingest job {job_id} did not finish in time")
    time.sleep(2)
    poll = requests.get(
        f"https://api.enconvert.com/v2/ingest/{job_id}",
        headers=HEADERS,
        timeout=30,
    )
    poll.raise_for_status()
    job = poll.json()
    if job["status"] == "completed":
        break
    if job["status"] in ("failed", "canceled"):
        raise RuntimeError(f"Ingest {job['status']}: {job.get('error_message')}")

# Step 3: Download the JSONL, one chunk per line. The output_url is signed
# (valid 15 minutes), so fetch it with a plain GET and no API key.
jsonl_response = requests.get(job["output_url"], timeout=60)
jsonl_response.raise_for_status()

# Step 4: Turn each chunk into a LlamaIndex node, so the chunks stay exactly as
# EnConvert split them (no SentenceSplitter re-chunking). headings_path is a
# list, so join it into one string to keep the metadata flat.
nodes = []
for line in jsonl_response.content.decode("utf-8").split("\n"):
    if not line.strip():
        continue
    chunk = json.loads(line)
    metadata = chunk["metadata"]
    nodes.append(
        TextNode(
            id_=chunk["id"],
            text=chunk["content"],
            metadata={
                "source_url": metadata["source_url"],
                "title": metadata["title"],
                "headings_path": " > ".join(metadata["headings_path"]),
                "section": metadata["section"],
                "word_count": metadata["word_count"],
                "chunk_index": metadata["chunk_index"],
            },
            # Embed the heading breadcrumb with the text, not the bookkeeping keys
            excluded_embed_metadata_keys=["source_url", "word_count", "chunk_index"],
        )
    )

index = VectorStoreIndex(nodes)

# Step 5: Retrieve from one section of the document with a MetadataFilter
print("Sections in this file:", sorted({node.metadata["section"] for node in nodes}))

filters = MetadataFilters(
    # Replace "Technical Requirements" with a heading from your own DOCX
    filters=[MetadataFilter(key="section", value="Technical Requirements")]
)
retriever = index.as_retriever(filters=filters)
for result in retriever.retrieve("What are the API rate limits?"):
    print(f"[{result.node.metadata['headings_path']}] {result.node.text}\n")

What you get back

Flat paragraph extraction via python-docx gives you text with no heading hierarchy, stripped tables, and chunks that cross section boundaries with no structural metadata to filter against; versus EnConvert returning chunks split on actual H1 through H4 boundaries from the document XML, each carrying a full headings_path breadcrumb that makes section-level MetadataFilters work out of the box.

Get free API key — 500 ops per month, no card required.