A developer building a RAG pipeline that ingests DOCX files into Chroma needs heading-aware chunks with section metadata, not flat paragraph text that loses the document’s heading structure. A library like python-docx gives you a flat sequence of paragraphs where heading levels appear only as style names you have to traverse and group yourself. There are no chunk boundaries and no metadata a Chroma filter can operate on. For an agent reading a product specification, an internal policy document, or a technical brief, paragraph text without heading context produces chunks the agent cannot locate within the document or filter by section. EnConvert’s Ingest endpoint takes the DOCX upload and returns heading-aware JSONL chunks, each carrying its full headings_path breadcrumb, section name, and chunk index in metadata, so structured DOCX chunks are ready for embedding and retrieval in one call.
How to Ingest DOCX into Chroma with EnConvert
Use EnConvert to ingest a DOCX into Chroma as heading-aware chunks with section metadata, ready to add as documents and filter by section before vector search
Get API keyExample
python
import os
import json
import time
import chromadb
import requests
from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction
HEADERS = {"X-API-Key": os.environ["ENCONVERT_API_KEY"]}
DOCX_PATH = "/path/to/your/product-specification.docx"
# Step 1: Upload the DOCX (the multipart field is "files", plural)
with open(DOCX_PATH, "rb") as f:
upload = requests.post(
"https://api.enconvert.com/v2/ingest/files",
headers=HEADERS,
files={
"files": (
os.path.basename(DOCX_PATH),
f,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
},
timeout=120,
)
upload.raise_for_status()
job_id = upload.json()["job_id"]
# Step 2: Poll until the job completes
while True:
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')}")
time.sleep(2)
# Step 3: Download the JSONL output
# output_url is a signed URL (valid 15 minutes). Plain GET, no API key.
jsonl = requests.get(job["output_url"], timeout=60)
jsonl.raise_for_status()
# Split on "\n" only: splitlines() would also break on U+2028 inside chunk text
chunks = [
json.loads(line)
for line in jsonl.content.decode("utf-8").split("\n")
if line.strip()
]
print(f"Loaded {len(chunks)} chunks ready for embedding")
# Step 4: Connect to Chroma and create or open the collection
client = chromadb.PersistentClient(path="./chroma_db")
embedding_function = SentenceTransformerEmbeddingFunction(
model_name="all-MiniLM-L6-v2"
)
collection = client.get_or_create_collection(
name="docx_chunks",
embedding_function=embedding_function,
)
# Step 5: Add each chunk to Chroma with metadata preserved
# Chunk ids are deterministic, so upsert keeps a re-run from duplicating chunks.
# headings_path is a list (empty for text before the first heading), so join it
# into one string: Chroma rejects empty-list metadata values.
collection.upsert(
ids=[c["id"] for c in chunks],
documents=[c["content"] for c in chunks],
metadatas=[
{
"section": c["metadata"]["section"],
"headings_path": " > ".join(c["metadata"]["headings_path"]),
"chunk_index": c["metadata"]["chunk_index"],
"source_url": c["metadata"]["source_url"], # the filename for uploads
}
for c in chunks
],
)
print(f"Added {len(chunks)} chunks to Chroma")
# Step 6: Similarity search narrowed to one section by a metadata filter
results = collection.query(
query_texts=["supported operating systems"],
n_results=3,
where={"section": "System Requirements"}, # replace with a heading from your DOCX
include=["documents", "metadatas", "distances"],
)
for doc, meta, distance in zip(
results["documents"][0], results["metadatas"][0], results["distances"][0]
):
print(f"{meta['headings_path']} (distance {distance:.3f})")
print(doc[:200])
What you get back
Ingest returns the DOCX as heading-aware JSONL chunks where each chunk carries its full headings_path breadcrumb, section name, and chunk index, so Chroma metadata filters can narrow retrieval to a specific section before vector search runs. Extracting content with python-docx gives flat paragraph text, with heading levels left as style names you must group yourself and no metadata Chroma can filter on.