Skip to content
Blog

Building an AI Research Assistant End-to-End

Go from a prompt to a working research assistant: ingest papers and sources, index them for retrieval, let an agent plan and answer, and surface citations you can trust.

Published on August 9, 2026

AI Assistant

Research is the perfect RAG use case — until you try to do it for real. Papers are long, questions are multi-part (“compare the eval methodology of these two papers”), and an answer without citations is worse than no answer. A research assistant isn’t a single “ask and answer” call; it’s a pipeline that ingests sources, indexes them, plans a retrieval strategy, and synthesizes a cited answer.

In this post, you will learn how to build a research assistant end-to-end with LlamaIndex: document ingestion with rich metadata, a persistent index, an agent that retrieves and answers, and citations wired into every response.

Prerequisites

  • Python 3.10+, pip install llama-index-core llama-index-llms-gemini llama-index-embeddings-gemini
  • A Gemini API key (or any LlamaIndex LLM of your choice)

1. Ingest and enrich your sources

Research documents carry structure — title, authors, year, section — and that metadata is what lets the assistant filter and cite. Load PDFs and enrich each chunk with extracted metadata:

from llama_index.core import Settings, SimpleDirectoryReader, Document
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.extractors import TitleExtractor, MetadataExtractor
from llama_index.core.ingestion import IngestionPipeline
from llama_index.llms.gemini import Gemini
from llama_index.embeddings.gemini import GeminiEmbedding

Settings.llm = Gemini(model="models/gemini-2.5-pro", api_key="...")
Settings.embed_model = GeminiEmbedding(model="models/text-embedding-004", api_key="...")

docs = SimpleDirectoryReader("papers/", filename_as_id=True).load_data()

pipeline = IngestionPipeline(
    transformations=[
        SentenceSplitter(chunk_size=512, chunk_overlap=64),
        MetadataExtractor(
            extractors=[TitleExtractor(nodes=5, llm=Settings.llm)],
            inplace=False,
        ),
        Settings.embed_model,
    ],
)
nodes = pipeline.run(documents=docs)

Because filename_as_id=True, every node is traceable back to its source file — that’s your citation chain.

2. Index with metadata you can filter on

A research corpus needs filtering (“only papers from 2024”, “only the methods section”). Store the index in a persistent vector store and rely on node metadata for filters:

from llama_index.core import VectorStoreIndex, StorageContext
from llama_index.core.storage.docstore import SimpleDocumentStore
from llama_index.core.storage.index_store import SimpleIndexStore

storage = StorageContext.from_defaults(
    docstore=SimpleDocumentStore.from_documents(nodes),
    index_store=SimpleIndexStore.from_documents(nodes),
)
index = VectorStoreIndex(nodes, storage_context=storage)
index.storage_context.persist("research_index")

Filtered retrieval at query time is a keyword filter — no extra embedding pass:

query_engine = index.as_query_engine(
    similarity_top_k=5,
    filters={"title": {"$in": ["Attention Is All You Need", "BERT"]}},
)

3. Give the agent planning and tools

A single query engine struggles with multi-part research questions. Turn the assistant into an agent with tools: a research_index tool for the corpus and a summarize tool for follow-up synthesis:

from llama_index.core.agent import FunctionCallingAgentWorker, AgentRunner
from llama_index.core.tools import QueryEngineTool, ToolMetadata

research_tool = QueryEngineTool(
    query_engine=query_engine,
    metadata=ToolMetadata(
        name="research_index",
        description="Search the indexed papers. Use for factual questions about them.",
    ),
)

agent = AgentRunner(
    FunctionCallingAgentWorker.from_tools(
        [research_tool],
        llm=Settings.llm,
        verbose=True,
    )
)

answer = agent.chat(
    "Compare how BERT and the Transformer encoder differ in training objective, "
    "and cite the sections you used."
)

The agent decides when to search, which filter to apply, and when it has enough to answer.

4. Wire citations into the response

An answer the user can’t verify is a hallucination risk. Extract the source nodes behind the final answer and render them as citations:

source_nodes = query_engine.retrieve(answer.response.split("\n")[0])

citations = []
for node in source_nodes:
    citations.append({
        "file": node.metadata.get("file_name"),
        "text": node.get_text()[:200],
        "score": round(node.score, 3),
    })

Store the trace (query → tool calls → source nodes → answer) so every claim is auditably grounded.

Putting It All Together

The full assistant — ingestion with metadata, persistent index, agent with tools, and a citation renderer — is in this gist. Run it against a folder of 3–5 real papers and watch the agent plan, retrieve with filters, and answer with citations in under a minute.

Conclusion & Next Steps

You’ve built a research assistant that ingests sources, retrieves with metadata filters, reasons with an agent, and cites its answers. Next steps: add a summarize_globally tool for corpus-level surveys, persist the agent’s conversation memory so follow-ups build on context, and log every trace to an observability backend so you can audit why it answered the way it did.

References / Sources