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. 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