---
title: "Ingest DOCX into Chroma with EnConvert"
description: "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"
canonical: "https://www.enconvert.com/solutions/ingest-docx-into-chroma-with-enconvert"
locale: "en"
---

# How to Ingest DOCX into Chroma with EnConvert

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])
```

## Output

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 started

[Get free API key — 500 ops per month, no card required.](https://www.enconvert.com/auth?mode=signup)

## Related solutions

- [How to Ingest Markdown Files into LlamaIndex with EnConvert](https://www.enconvert.com/solutions/ingest-markdown-files-into-llamaindex-with-enconvert.md)
- [How to Ingest Web Pages into Vertex AI with EnConvert](https://www.enconvert.com/solutions/ingest-web-pages-into-vertex-ai-with-enconvert.md)
- [How to Ingest Markdown into OpenAI File Search with EnConvert](https://www.enconvert.com/solutions/ingest-markdown-into-openai-file-search-enconvert.md)
- [How to Ingest HTML Files into LlamaIndex with EnConvert](https://www.enconvert.com/solutions/ingest-html-files-into-llamaindex-with-enconvert.md)
- [Best Browserless Alternative for Document Parsing](https://www.enconvert.com/solutions/browserless-alternative-for-document-parsing-enconvert.md)
- [How to Ingest DOCX into CrewAI with EnConvert](https://www.enconvert.com/solutions/ingest-docx-into-crewai-with-enconvert.md)
