LlamaIndex PDF RAG Chatbot

Instrument a LlamaIndex PDF RAG chatbot with traceAI, score retrieval and generation with Future AGI evals, and alert on quality drops.

📝
TL;DR

Build a PDF-grounded RAG chatbot with LlamaIndex, instrument it with traceAI so every embedding, retrieval, and generation step becomes a span, then attach Future AGI evals to score task completion, hallucination, and context relevance on each trace.

The full application (Gradio UI, ingestion, chat loop) lives in the llamaindex integration repo. This cookbook walks through the instrumentation and evaluation layer you add on top of it.

TimeDifficultyPackage
30 minIntermediatetraceAI-llamaindex
Prerequisites

Install

pip install traceAI-llamaindex llama-index
export FI_API_KEY="your-api-key"
export FI_SECRET_KEY="your-secret-key"
export OPENAI_API_KEY="your-openai-key"

Tutorial

Instrument the app with traceAI

traceAI-llamaindex auto-instruments LlamaIndex so every embedding, retrieval, and LLM call becomes a span with model name, token usage, prompt, and chunk metadata attached.

from fi_instrumentation import register
from fi_instrumentation.fi_types import ProjectType
from traceai_llamaindex import LlamaIndexInstrumentor

trace_provider = register(
    project_type=ProjectType.OBSERVE,
    project_name="llamaindex_project",
)

LlamaIndexInstrumentor().instrument(tracer_provider=trace_provider)

register() sets up an OpenTelemetry tracer that ships spans to Future AGI. LlamaIndexInstrumentor().instrument() patches LlamaIndex so every operation after this call is traced automatically, no manual span code in the app itself.

You should see no output here. The instrumentation is silent until the app runs a query.

Ingest PDFs into a persistent vector index

from pathlib import Path
from llama_index.core import Settings, SimpleDirectoryReader, VectorStoreIndex
from llama_index.embeddings.openai import OpenAIEmbedding

Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-large")

DOCUMENTS_PATH = Path("./documents")   # uploaded PDFs land here
STORAGE_PATH = Path("./vectorstore")   # persisted embeddings, survives restarts

docs = SimpleDirectoryReader(str(DOCUMENTS_PATH), recursive=True).load_data()
index = VectorStoreIndex.from_documents(docs)
index.storage_context.persist(persist_dir=str(STORAGE_PATH))

SimpleDirectoryReader parses each PDF into nodes. VectorStoreIndex embeds every node with text-embedding-3-large and writes the index to ./vectorstore so later runs don’t re-embed the same files.

You should see one embedding span per chunk under the ingestion chain span in the Observe trace view once you open the project. That’s your first trace, before you’ve even sent a chat message.

Query the index and generate a grounded answer

from llama_index.core.memory import ChatMemoryBuffer

CHAT_MEMORY_TOKEN_LIMIT = 3900  # leaves room for retrieved chunks in a 4k-token context window

memory = ChatMemoryBuffer.from_defaults(token_limit=CHAT_MEMORY_TOKEN_LIMIT)
engine = index.as_chat_engine(memory=memory)

response = engine.chat("What is the refund window for a defective product?")
print(response.response)

for node in response.source_nodes:
    print(node.metadata.get("file_name"), node.metadata.get("page_label"), node.score)

engine.chat() embeds the query, retrieves the top-matching chunks, and generates an answer grounded in them. response.source_nodes carries the file name, page, and similarity score for each chunk the assistant used, which is what the app shows as citations.

You should see the answer text printed, followed by one line per cited chunk with its source file and page.

Open the llamaindex_project project in Future AGI Observe now. This question produced a trace containing an Embedding span, a Retriever span, and an LLM span.

Future AGI Observe trace view for a LlamaIndex query, with the embedding, retriever, and LLM spans listed on the left and eval results in the bottom panel

The span hierarchy on the left, query and response on the right, eval results at the bottom

Inspect the trace in Observe

Open that trace and read it span by span.

You should see the retriever span listing the chunks it selected, with file_name, page_label, and a similarity score for each.

Attach evals to spans

In the dashboard, define evals as tasks and attach them to a span type rather than calling evaluate() from code. This scores every trace as it’s generated, not just the ones you happen to test locally.

Future AGI dashboard panel for attaching an eval task to a span type in the llamaindex_project

Attaching Task Completion, Detect Hallucination, Context Relevance, and Context Adherence to the LLM span type

You should see the new eval task listed against the span type you selected, and it starts scoring the next trace that hits that span.

Tip

Read more about the built-in evals in Evaluation, and about writing your own in Creating custom evals.

Read the eval results

Reopen a trace after the eval task has run. The bottom panel now shows a score per span.

In one example run: Task Completion passed, Detect Hallucination passed, Context Adherence scored 80% (most of the response stayed within retrieved context), and Context Relevance scored 40% (retrieval surfaced only partially useful chunks). These numbers are illustrative from a single run, not a benchmark.

Future AGI charts dashboard showing eval score trends alongside latency and cost for the llamaindex_project

Score trends over time, next to latency and cost, for spotting drift before it reaches customers

You should see a low Context Relevance score point at the retriever, not the generator: the fix is chunking or top_k, not the prompt.

Fix the retriever and rerun

The retriever is the suspect, so change one knob on it: raise similarity_top_k from the default of 2 to 5 so the retriever pulls more candidate chunks per query.

engine = index.as_chat_engine(memory=memory, similarity_top_k=5)

response = engine.chat("What is the refund window for a defective product?")
print(response.response)

Rerun the same question and reopen the new trace. In this run, Context Relevance moved from 40% to 75% with similarity_top_k=5: the extra candidates gave the retriever more of the chunks it needed, at the cost of a slightly larger prompt. This delta is illustrative from a single before/after run, not a benchmark.

You should see the new trace’s Context Relevance score sitting well above the 40% baseline from the previous step.

Set an alert on a quality metric

Once you have a baseline, set a threshold so a drop pages you instead of a customer.

Future AGI alert rule creation form with a metric selector, monitoring interval, and threshold fields

Selecting Context Relevance as the metric, with a threshold below which the alert fires

Future AGI alerts dashboard listing alerts by type with their healthy or triggered status

Triggered and healthy alerts across the project, with the time each was last triggered

You should see the alert listed as Healthy until a trace crosses your threshold, at which point it flips to Triggered and notifies the channel you configured.

Troubleshooting

SymptomCauseFix
NameError: name 'LlamaIndexInstrumentor' is not definedInstantiated LlamaIndexInstrumentor() without importing itAdd from traceai_llamaindex import LlamaIndexInstrumentor before calling register()
No spans appear in the Observe dashboardregister() wasn’t called, or instrument() ran before register() returnedCall register(project_type=ProjectType.OBSERVE, ...) first, then pass its return value into LlamaIndexInstrumentor().instrument(tracer_provider=...)
401 Unauthorized when spans try to shipFI_API_KEY or FI_SECRET_KEY not set, or set after the script imports fi_instrumentationExport both keys before running the script, not inside it
Queries return an empty or generic answerThe PDF is scanned or image-only, so SimpleDirectoryReader extracted no textRun OCR on the file before ingestion, or check docs isn’t empty before building the index
New PDFs don’t show up in answersThe old ./vectorstore wasn’t cleared before rebuildingDelete ./vectorstore (or call your app’s rebuild_index()) before re-ingesting
Context Relevance scores stay low across runsChunk size doesn’t match the PDFs’ structure, or the query embedding drifts from the chunk embeddingsTune the chunk size and top_k on the retriever, then rerun the same questions and compare the score
Ingesting a large PDF is slowEvery chunk sends a separate embedding call, run sequentiallyBatch the embedding calls, or reduce chunk count with a larger chunk size

Next: score retrieval and generation independently in RAG Evaluation: Retrieval vs Generation.

Was this page helpful?

Questions & Discussion