---
title: "Ingest PDF into LangChain with EnConvert"
description: "Use EnConvert to ingest a PDF into LangChain as heading-aware chunks with section metadata, ready to load as Document objects and add to a vector store"
canonical: "https://www.enconvert.com/solutions/ingest-pdf-into-langchain-with-enconvert"
locale: "en"
---

# How to Ingest a PDF into LangChain with EnConvert

A developer building a LangChain agent or RAG pipeline that reads PDFs needs heading-aware chunks with section metadata, not flat page text that loses document structure. LangChain’s built-in PDF loaders such as PyPDFLoader and PDFPlumberLoader split documents by page, producing one chunk per page with no heading hierarchy, no section boundaries, and no structural metadata a retrieval layer can filter on. For an agent reading a technical manual, a research paper, or a product specification, flat page chunks produce results the agent cannot locate within the document or filter by section. EnConvert’s Ingest endpoint takes the PDF upload and returns heading-aware JSONL chunks, each carrying its full headings_path breadcrumb, section name, and chunk index in metadata, so structured PDF chunks are ready for embedding and retrieval in one call.

## Example

```python
import os
import json
import time

import requests
from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_openai import OpenAIEmbeddings

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

# Step 1: Upload the PDF (the multipart field is named "files"; the job is queued)
with open("/path/to/your/product-specification.pdf", "rb") as pdf_file:
    upload = requests.post(
        "https://api.enconvert.com/v2/ingest/files",
        headers=HEADERS,
        files={"files": ("product-specification.pdf", pdf_file, "application/pdf")},
        timeout=60,
    )
upload.raise_for_status()
job_id = upload.json()["job_id"]

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

# Step 3: Download the JSONL output. The output URL is signed (valid 15 minutes):
# plain GET, no API key.
jsonl_response = requests.get(output_url, timeout=60)
jsonl_response.raise_for_status()

# Step 4: Load each chunk as a LangChain Document with metadata preserved
documents = []
# Splitting the raw bytes only breaks on newlines, so a chunk's text can never
# split a JSON line.
for line in jsonl_response.content.splitlines():
    if not line.strip():
        continue
    chunk = json.loads(line)
    documents.append(
        Document(
            id=chunk["id"],
            page_content=chunk["content"],
            # source_url, title, headings_path, section, word_count, chunk_index.
            # InMemoryVectorStore keeps metadata as plain Python objects, so the
            # headings_path list is stored as is.
            metadata=chunk["metadata"],
        )
    )

print(f"Loaded {len(documents)} chunks ready for embedding")
print("Sections:", sorted({doc.metadata["section"] for doc in documents}))

# Step 5: Embed and add to a vector store
# (InMemoryVectorStore uses numpy for cosine search, so numpy must be installed)
embeddings = OpenAIEmbeddings(
    model="text-embedding-3-small",
    api_key=OPENAI_API_KEY,
)
vector_store = InMemoryVectorStore(embeddings)
vector_store.add_documents(documents)

# Step 6: Search only inside one section of the PDF
SECTION = "Operating Conditions"  # replace with a section printed above for your PDF
results = vector_store.similarity_search(
    "operating temperature limits",
    k=3,
    filter=lambda doc: doc.metadata.get("section") == SECTION,
)
for doc in results:
    print(doc.metadata["section"], doc.metadata["headings_path"])
    print(doc.page_content[:200])
```

## Output

Ingest returns the PDF as heading-aware JSONL chunks where each chunk carries its full headings_path breadcrumb, section name, and chunk index, so retrieval knows exactly where in the document a result came from. 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 use to filter or locate results.

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