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 key

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.

Example

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.

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