---
title: "Ingest a Technical Manual into LangChain | EnConvert"
description: "Use EnConvert to ingest a technical manual into LangChain as heading-aware chunks with section metadata, ready to load as Documents and filter by section"
canonical: "https://www.enconvert.com/solutions/ingest-a-technical-manual-into-langchain-enconvert"
locale: "en"
---

# How to Ingest a Technical Manual into LangChain with EnConvert

A developer building a LangChain agent or RAG pipeline that reads technical manuals needs heading-aware chunks with section metadata, not flat page text that loses the manual’s heading structure. LangChain’s built-in PDF loaders such as PyPDFLoader and PDFPlumberLoader split the manual by page, producing one chunk per page with no heading hierarchy and no section boundaries. Nothing populates structural metadata a retrieval layer can filter on. For an agent that must answer questions about installation procedures, configuration options, or safety requirements, flat page text without section context produces results the agent cannot locate within the manual or filter by section. EnConvert’s Ingest endpoint takes the uploaded manual 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 manual chunks are ready to load as LangChain Document objects and retrieve in one call.

## Example

```python
import os
import json
import time

import requests
from langchain_chroma import Chroma
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings

HEADERS = {"X-API-Key": os.environ["ENCONVERT_API_KEY"]}

# Step 1: Upload the technical manual
# Text-based PDFs only; scanned or image-only manuals are not processed
with open("/path/to/your/installation-manual.pdf", "rb") as f:
    upload = requests.post(
        "https://api.enconvert.com/v2/ingest/files",
        headers=HEADERS,
        files={"files": ("installation-manual.pdf", f, "application/pdf")},
        timeout=60,
    )
upload.raise_for_status()
job_id = upload.json()["job_id"]

# Step 2: Poll until the job completes, failing on error or after 10 minutes
deadline = time.monotonic() + 600
while True:
    status_response = requests.get(
        f"https://api.enconvert.com/v2/ingest/{job_id}",
        headers=HEADERS,
        timeout=30,
    )
    status_response.raise_for_status()
    job = status_response.json()
    if job["status"] == "completed":
        output_url = job["output_url"]
        break
    if job["status"] in ("failed", "canceled"):
        raise RuntimeError(job.get("error_message") or f"Ingest job {job['status']}")
    if time.monotonic() > deadline:
        raise TimeoutError(f"Ingest job {job_id} is still {job['status']} after 10 minutes")
    time.sleep(2)

# Step 3: Download the JSONL output
# The signed URL expires after 15 minutes. Plain GET, no API key.
jsonl = requests.get(output_url, timeout=60)
jsonl.raise_for_status()

# The file is UTF-8, one JSON object per line
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: Load each chunk as a LangChain Document with metadata preserved
# Do not apply another text splitter - heading-aware boundaries are already set
# headings_path is a list and can be empty, and Chroma metadata does not accept empty
# arrays, so it is stored as a breadcrumb string
documents = [
    Document(
        page_content=chunk["content"],
        metadata={
            "source_url": chunk["metadata"]["source_url"],
            "title": chunk["metadata"]["title"],
            "section": chunk["metadata"]["section"],
            "headings_path": " > ".join(chunk["metadata"]["headings_path"]),
            "chunk_index": chunk["metadata"]["chunk_index"],
            "word_count": chunk["metadata"]["word_count"],
        },
    )
    for chunk in chunks
]

# Step 5: Add to a Chroma vector store persisted on disk
# Chunk ids from uploaded files are new on every ingest job, so delete ./chroma_manual_index
# before re-ingesting the same file, or the old chunks stay in the collection
embeddings = OpenAIEmbeddings(
    model="text-embedding-3-small",
    api_key=os.environ["OPENAI_API_KEY"],
)
vectorstore = Chroma.from_documents(
    documents,
    embedding=embeddings,
    ids=[chunk["id"] for chunk in chunks],
    collection_name="technical_manual",
    persist_directory="./chroma_manual_index",
)

# Step 6: Similarity search restricted to one section by a metadata filter
# The section headings found in your manual, to pick a filter value from
print(sorted({doc.metadata["section"] for doc in documents}))

# Chroma applies the filter before the nearest-neighbour search, so every result comes
# from the chosen section (fewer than k if the section is small)
results = vectorstore.similarity_search(
    "what must be checked before powering on the unit",
    k=3,
    # Replace with a section heading from your own manual (see the list above)
    filter={"section": "Safety Precautions"},
)
for doc in results:
    print(doc.metadata["section"])
    print(doc.metadata["headings_path"])
    print(doc.page_content[:200])

# To reload the index later:
# vectorstore = Chroma(
#     collection_name="technical_manual",
#     embedding_function=embeddings,
#     persist_directory="./chroma_manual_index",
# )
```

## Output

Ingest returns the manual as JSONL chunks split at H1 to H3 heading boundaries, each carrying its headings_path breadcrumb, section name, and chunk index, so LangChain retrieval can filter by section before vector search runs. LangChain’s PyPDFLoader or PDFPlumberLoader splits by page, producing one flat block per page with no heading hierarchy, no section boundaries, and no metadata the retrieval layer can filter on. The endpoint processes text-based PDFs only; scanned or image-only manuals are not processed.

## 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)
