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
- LlamaIndex RAG and indexing docs. https://docs.llamaindex.ai
- LlamaIndex agents and tools. https://docs.llamaindex.ai/en/stable/understanding/agent/
- LlamaIndex metadata extraction. https://docs.llamaindex.ai/en/stable/module_guides/loading/documents_and_nodes/usage_metadata_extractor/