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.
How to Ingest a PDF into LangChain with EnConvert
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
Get API keyExample
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])
What you get back
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.