This llamaindex pdf ingestion n4n.ai tutorial shows how to pipe a PDF through LlamaIndex’s reader and embed it with an OpenAI-compatible endpoint. We’ll use n4n.ai’s gateway as the embedding provider because it fronts a wide model catalog behind a single base URL. By the end you’ll have a queryable vector index built from a real document and a clear pattern for scaling to thousands of files.
Step 1: Scaffold the project and install dependencies
Start in a clean directory. LlamaIndex ships as a meta package that pulls in core, the OpenAI embedding integration, and a set of default readers. Isolate the environment so version drift in pypdf doesn’t break parsing later.
mkdir pdf_ingest && cd pdf_ingest
python -m venv .venv && source .venv/bin/activate
pip install llama-index python-dotenv pypdf
pypdf is the underlying parser LlamaIndex uses for PDFs; installing it explicitly avoids silent fallback to a slower pure-Python path. If you plan to ingest scanned PDFs, add pdf2image and a Tesseract binary, but that is out of scope for this walkthrough.
Create a .env file to keep credentials out of source control:
echo "N4N_API_KEY=sk-your-key-here" > .env
The remainder of this llamaindex pdf ingestion n4n.ai tutorial assumes Python 3.10 or newer and that you have a sample PDF at ./sample.pdf. Put any PDF there—an annual report, a product manual, or a research paper works fine.
Step 2: Point LlamaIndex at the n4n.ai embeddings endpoint
LlamaIndex decouples the embedding model from the indexing logic via a global Settings object. The OpenAIEmbedding class speaks the OpenAI /v1/embeddings contract, so any compliant gateway works by overriding api_base.
import os
from dotenv import load_dotenv
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index import Settings
load_dotenv()
Settings.embed_model = OpenAIEmbedding(
model="text-embedding-3-small",
api_base="https://api.n4n.ai/v1",
api_key=os.getenv("N4N_API_KEY"),
)
n4n.ai exposes an OpenAI-compatible endpoint that fronts 240+ models and handles provider degradation automatically, so the same code paths work if you later swap to a different embedding model by changing the model string. The gateway forwards provider cache-control hints, which trims cost on repeated prefix chunks when you re-ingest revised PDFs.
Set a chunk size that matches your downstream retriever. The default 1024 tokens with 20-token overlap is fine for most reports; legal or scientific PDFs often benefit from 512-token chunks because they use dense nomenclature.
from llama_index.core.node_parser import SentenceSplitter
Settings.node_parser = SentenceSplitter(chunk_size=1024, chunk_overlap=20)
Embedding dimension is fixed by the model: text-embedding-3-small returns 1536-dim vectors. If you switch to a local model later, change both model and the vector store configuration accordingly.
Step 3: Load the PDF with LlamaIndex’s file reader
LlamaIndex treats a PDF as a collection of Document objects. The simplest entry point is SimpleDirectoryReader, which selects PDFReader based on extension and yields one Document per page.
from llama_index import SimpleDirectoryReader
documents = SimpleDirectoryReader(input_files=["sample.pdf"]).load_data()
print(f"Loaded {len(documents)} raw page documents")
If you need finer control—say, extracting only pages 3–10 or attaching custom metadata per page—use PDFReader directly:
from pathlib import Path
from llama_index.readers.file import PDFReader
reader = PDFReader()
documents = reader.load_data(file=Path("sample.pdf"))
At this stage the text is raw. LlamaIndex will apply the node_parser from Step 2 during indexing, splitting pages into retrievable nodes. You can inspect a node prematurely to confirm parsing quality:
nodes = Settings.node_parser.get_nodes_from_documents(documents)
print(f"First node preview: {nodes[0].get_content()[:200]!r}")
A empty preview string means the PDF is image-only or encrypted. Decrypt with qpdf or OCR before proceeding.
Step 4: Build and persist the vector index
With documents loaded and embeddings configured, index construction is one call. LlamaIndex batches embedding requests internally; for a 40-page PDF this is a few hundred tokens per node and finishes in seconds.
from llama_index import VectorStoreIndex
index = VectorStoreIndex.from_documents(documents)
To avoid re-embedding on every restart, persist the index to disk. LlamaIndex stores nodes and vectors in a StorageContext.
index.storage_context.persist(persist_dir="./storage")
Reload later without touching the embedding endpoint:
from llama_index import StorageContext, load_index_from_storage
ctx = StorageContext.from_defaults(persist_dir="./storage")
index = load_index_from_storage(ctx)
For production, swap the default in-memory store for a managed vector database (Qdrant, pgvector, Pinecone). The from_documents call stays identical; only the storage_context changes.
Step 5: Query to verify ingestion succeeded
A vector index is useless if the embeddings are empty or the text was mangled. Run a targeted query against content you know exists in the PDF.
query_engine = index.as_query_engine(similarity_top_k=3)
response = query_engine.query("What are the eligibility requirements for a refund?")
print(str(response))
Verification checklist:
- The response cites specifics from your PDF, not generic filler.
response.source_nodesis non-empty and each node’sscoreis a float between 0 and 1.- Running the script twice with a persisted index does not trigger new embedding calls (watch your n4n.ai usage meter or stdout if you enable
httpxlogging).
Inspect the provenance of the answer programmatically:
for node in response.source_nodes:
print(node.score, node.metadata.get("page_label"), node.get_content()[:80])
If source_nodes is empty, the PDF likely failed parsing—print documents[0].text to confirm it isn’t empty strings from a scanned image.
Step 6: Production hardening for PDF ingestion
The happy path above hides three issues you will hit in real pipelines.
Metadata propagation
LlamaIndex attaches metadata per document. Stamp a source tag so you can filter later:
for d in documents:
d.metadata["source"] = "sample.pdf"
d.metadata["ingested_at"] = "2024-05-01"
Batch size and rate limits
The OpenAIEmbedding client sends embeddings in batches of 10 by default. If your gateway enforces stricter RPM, lower it:
Settings.embed_model.embed_batch_size = 5
Concurrency and fallback
Because the endpoint honors client routing directives, you can pin a specific provider in the header if a model misbehaves:
Settings.embed_model.additional_session_headers = {
"x-n4n-route": "openai"
}
This is optional; the automatic fallback already covers degraded providers. For large corpora, wrap ingestion in a worker pool and cap concurrent from_documents calls to avoid memory spikes from node buffering.
Verifying end-to-end success
Beyond the query check, confirm the index on disk contains vectors. A quick sanity script:
import json
from llama_index import StorageContext, load_index_from_storage
ctx = StorageContext.from_defaults(persist_dir="./storage")
index = load_index_from_storage(ctx)
print(f"Indexed nodes: {len(index.docstore.docs)}")
If the count matches your expected page chunks (pages × chunks-per-page), ingestion worked. This llamaindex pdf ingestion n4n.ai tutorial deliberately stays single-file; in a larger system you would wrap Steps 3–4 in a worker that watches an object store bucket and writes completion status to a queue.
One last note: LlamaIndex’s PDFReader does not OCR. For image-only PDFs, preprocess with pdftotext or a vision model before ingestion, otherwise you’ll embed empty strings and the query engine will confidently hallucinate. Treat the embedding step as a pure function of text quality—garbage in, garbage retrieved.