A developer building a LlamaIndex agent or RAG pipeline that reads DOCX files needs heading-aware chunks with section metadata, not flat paragraph text that loses the document’s heading structure. A library like python-docx extracts content paragraph by paragraph, and heading levels appear only as style names you have to traverse and group yourself, with no chunk boundaries and no metadata a LlamaIndex retriever can filter on. For an agent reading a product specification, an internal policy document, or a technical brief, flat paragraph text produces retrieval results the agent cannot locate within the document or filter by section. EnConvert’s Ingest endpoint takes the uploaded DOCX and returns JSONL chunks split at H1 to H3 heading boundaries, each carrying its full headings_path breadcrumb, section name, and chunk index in metadata, so structured DOCX chunks are ready to load into LlamaIndex and retrieve in one job.
How to Ingest DOCX into LlamaIndex with EnConvert
Use EnConvert to ingest DOCX into LlamaIndex as heading-aware chunks with section metadata, ready to load as nodes and filter retrieval by section in one job
Get API keyExample
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 (
FilterOperator,
MetadataFilter,
MetadataFilters,
)
ENCONVERT_API_KEY = os.environ["ENCONVERT_API_KEY"]
# LlamaIndex embeds and answers with OpenAI by default and reads OPENAI_API_KEY
# from the environment.
# Step 1: Upload the DOCX. The multipart field is named "files" (plural).
with open("/path/to/your/product-specification.docx", "rb") as f:
upload = requests.post(
"https://api.enconvert.com/v2/ingest/files",
headers={"X-API-Key": ENCONVERT_API_KEY},
files={
"files": (
"product-specification.docx",
f,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
},
timeout=60,
)
upload.raise_for_status()
job_id = upload.json()["job_id"]
# Step 2: Poll until the job completes
while True:
status_response = requests.get(
f"https://api.enconvert.com/v2/ingest/{job_id}",
headers={"X-API-Key": ENCONVERT_API_KEY},
timeout=60,
)
status_response.raise_for_status()
job = status_response.json()
if job["status"] == "completed":
break
if job["status"] in ("failed", "canceled"):
raise RuntimeError(
f"Ingest job {job_id} {job['status']}: {job.get('error_message')}"
)
time.sleep(2)
# Step 3: Download the JSONL output, one chunk per line. The output_url is a
# signed URL valid for 15 minutes. Plain GET, no API key.
jsonl_response = requests.get(job["output_url"], timeout=60)
jsonl_response.raise_for_status()
chunks = [
json.loads(line)
for line in jsonl_response.content.decode("utf-8").split("\n")
if line.strip()
]
print(f"Loaded {len(chunks)} chunks ready for embedding")
# Step 4: Load each chunk as a TextNode so LlamaIndex does not re-split it.
# EnConvert already split at H1 to H3 boundaries (512 word soft cap), keeping
# tables and code blocks whole even when they exceed the cap.
nodes = [
TextNode(
id_=chunk["id"],
text=chunk["content"],
metadata={
"source_url": chunk["metadata"]["source_url"], # the filename here
"title": chunk["metadata"]["title"],
"section": chunk["metadata"]["section"],
# headings_path is a list, so join it into one scalar string
"headings_path": " > ".join(chunk["metadata"]["headings_path"]),
"chunk_index": chunk["metadata"]["chunk_index"],
"word_count": chunk["metadata"]["word_count"],
},
excluded_embed_metadata_keys=[
"source_url",
"headings_path",
"chunk_index",
"word_count",
],
)
for chunk in chunks
]
# Step 5: Build the vector index
index = VectorStoreIndex(nodes=nodes)
# Step 6: Query narrowed to one section by a metadata filter.
# Replace "Authentication" with a heading from your own document.
query_engine = index.as_query_engine(
filters=MetadataFilters(
filters=[
MetadataFilter(
key="section",
value="Authentication",
operator=FilterOperator.EQ,
)
]
)
)
print(query_engine.query("What authentication requirements does this document specify?"))
What you get back
Ingest returns the DOCX as JSONL chunks split at H1 to H3 heading boundaries, with tables and code blocks kept whole even when they exceed the 512 word default soft cap, each carrying its headings_path breadcrumb, section name, and chunk index, so LlamaIndex retrieval can filter by section. Extracting content with python-docx gives flat paragraph text, with heading levels left as style names you must group yourself and no metadata LlamaIndex can filter on.