LlamaIndex Workflows for Event-Driven RAG
Source: mortalapps.com- LlamaIndex Workflows enable highly decoupled, event-driven retrieval pipelines using asynchronous step handlers.
- Solves the latency and rigidity bottlenecks of sequential, DAG-based RAG architectures by running multi-source retrieval in parallel.
- Provides type-safe event passing, native async execution, and dynamic routing to handle complex document synthesis.
- Delivers a production-ready pattern for aggregating parallel retrieval streams with robust error boundaries.
Why This Matters
Traditional RAG pipelines rely on rigid Directed Acyclic Graphs (DAGs) or linear chains. When building production-grade agentic systems, retrieval often requires dynamic branching, parallel multi-source querying, and asynchronous event handling. This is where llamaindex workflows event driven architectures become indispensable. By replacing rigid pipelines with an event-driven model, developers can build highly decoupled, reactive retrieval steps. Each step listens for specific events, processes data asynchronously, and emits new events. This approach prevents the "blocking step" bottleneck common in sequential architectures, allowing slow vector database queries, web searches, and document parsing tasks to execute in parallel. In production, ignoring this event-driven paradigm leads to high P99 latencies, thread pool exhaustion, and fragile error handling when a single retrieval source fails. LlamaIndex Workflows provide a native Python async framework that manages state, event propagation, and step execution without requiring external message brokers for in-memory coordination. It is the ideal choice when your RAG system needs to scale to multi-source retrieval, dynamic query decomposition, and self-corrective feedback loops, whereas simpler linear pipelines should be reserved only for basic, single-source search scenarios.
Core Concepts
To build event-driven RAG pipelines with LlamaIndex, you must understand the following core architectural primitives:
- Workflow: The orchestrator class (
Workflow) that manages step registration, event dispatching, and execution state. It acts as the runtime container for your agentic pipeline. - Event: Python classes inheriting from
Eventthat carry data between steps. Events are strongly typed, allowing Pydantic validation of payloads during step transitions. - Step: Methods decorated with
@stepthat accept an event, perform asynchronous operations (such as querying a vector database), and emit new events or return a final result. - Context: The
Contextobject passed to steps to store workflow-level state, manage concurrency, and handle step-to-step variables without polluting global scopes. - Event Aggregation: The mechanism (
ctx.collect_events) used to block a step's execution until a specified set of distinct events has been received, enabling map-reduce patterns.
How It Works
An event-driven RAG workflow operates as an asynchronous, reactive state machine. The lifecycle of a query execution flows through distinct phases, coordinating parallel tasks and aggregating results dynamically.
Phase 1: Workflow Initialization and Entry
Execution begins when the client calls workflow.run(query="..."). This call packages the input query into a StartEvent and dispatches it to the workflow's internal event loop. The step decorated to accept StartEvent acts as the entry point, typically performing query decomposition or intent analysis.
Phase 2: Dynamic Event Dispatching and Parallel Execution
Upon analyzing the query, the entry step emits multiple specialized events concurrently (e.g., VectorSearchEvent and KeywordSearchEvent). The workflow's event router detects these emissions and immediately invokes all step handlers registered to listen for these event types. Because these steps are asynchronous, they execute their database queries and API calls in parallel, eliminating sequential blocking.
Phase 3: Event Aggregation and State Synchronization
As each retrieval step completes, it emits a RetrievalResultEvent. The synthesis step, which is responsible for generating the final answer, is configured to listen for these results. It uses the workflow context to buffer incoming events. Using ctx.collect_events, the synthesizer waits until all expected retrieval events have arrived before proceeding, ensuring a complete context is assembled.
Phase 4: Synthesis and Output Generation
Once the aggregator confirms all parallel streams have completed, the synthesis step compiles the retrieved documents, constructs the final prompt, invokes the LLM, and returns a StopEvent containing the generated response. If a retrieval step fails or times out, the aggregator can trigger fallback logic, synthesizing an answer with partial data to maintain system availability.
Architecture
The architecture of a LlamaIndex Event-Driven RAG Workflow consists of five primary components coordinating in an asynchronous runtime. The Workflow Runner acts as the entry point, receiving the user query and bootstrapping the execution context. The Event Loop serves as the central nervous system, routing typed events to their registered step handlers. The Context Store maintains transient run-state, tracking active steps and buffering events. The Step Handlers represent isolated blocks of business logic: the Query Decomposer splits incoming queries, the Vector Retriever and BM25 Retriever execute parallel data fetches, and the Synthesizer aggregates results. Execution starts when the runner dispatches a StartEvent to the Query Decomposer. The decomposer emits parallel query events to the retrievers via the Event Loop. The retrievers query their respective data stores and return RetrievalEvents. These events flow into the Synthesizer, which buffers them in the Context Store. Once all events are collected, the Synthesizer invokes the LLM and returns a StopEvent to the runner, terminating the execution.
Designing Type-Safe Custom Events
In LlamaIndex Workflows, events are the API contracts between steps. Defining strict, type-safe events ensures that data corruption is caught at the boundary of a step rather than deep within execution logic. By subclassing Event from llama_index.core.workflow, you can utilize Pydantic's validation engine to enforce schemas on intermediate payloads.
from llama_index.core.workflow import Event
from pydantic import Field, BaseModel
from typing import List, Dict
class QueryDecomposedEvent(Event):
"""Emitted after splitting a complex query into sub-queries."""
original_query: str = Field(..., description="The raw user input query.")
sub_queries: List[str] = Field(..., description="Decomposed sub-queries for parallel execution.")
class DocumentChunk(BaseModel):
text: str
metadata: Dict[str, str]
class RetrievalResultEvent(Event):
"""Emitted by individual retriever steps containing search results."""
source_name: str = Field(..., description="Identifier of the retrieval source (e.g., 'vector', 'bm25').")
chunks: List[DocumentChunk] = Field(..., description="Retrieved document segments.")
Using Pydantic models within events guarantees that downstream steps, such as the synthesizer, can safely assume the structure of incoming data, preventing runtime KeyError exceptions during document formatting.
Managing Concurrency with Context and collect_events
One of the most powerful features of LlamaIndex Workflows is the ability to join parallel execution branches. The Context object (ctx) provides a helper method, collect_events, which acts as an asynchronous barrier. It buffers events of specified types and only fires the decorated step when all expected events have been collected.
@step
async def synthesize_response(
self, ctx: Context, ev: RetrievalResultEvent
) -> StopEvent | None:
# Buffer incoming RetrievalResultEvents
results = ctx.collect_events(
ev,
expected_num=2 # Wait for both Vector and BM25 retrievals
)
if results is None:
# Not all events have arrived yet; yield control back to the loop
return None
# Both events have arrived; process the aggregated results
combined_chunks = []
for result in results:
combined_chunks.extend(result.chunks)
# Proceed to LLM synthesis...
This pattern avoids complex lock management and thread synchronization. The workflow engine handles the buffering internally, ensuring that the synthesis step is invoked exactly once when the data is complete.
Dynamic Routing and Self-Correction Loops
Unlike rigid DAGs, event-driven workflows can dynamically route execution based on runtime data. For example, if the retrieved documents do not meet a specific relevancy threshold, the workflow can route the execution back to a query expansion step instead of proceeding to synthesis.
To prevent infinite loops in cyclic routing, you must maintain a step counter within the context's transient data store (ctx.data).
@step
async def evaluate_relevance(
self, ctx: Context, ev: RetrievalResultEvent
) -> SynthesisEvent | RetryQueryEvent:
# Initialize or increment the retry counter
retries = await ctx.get_metadata("retry_count", default=0)
if retries >= 3:
# Exceeded maximum retries; force synthesis with current data
return SynthesisEvent(chunks=ev.chunks)
# Evaluate relevance of chunks
is_relevant = self._assess_chunks(ev.chunks)
if not is_relevant:
await ctx.set_metadata("retry_count", retries + 1)
return RetryQueryEvent(query=ev.query)
return SynthesisEvent(chunks=ev.chunks)
Error Boundaries and Partial Failure Mitigation
In a multi-source retrieval pipeline, external dependencies (such as third-party APIs or vector databases) can fail or experience high latency. A robust workflow must handle these partial failures gracefully without crashing the entire execution run.
By wrapping external calls in try-except blocks within the step handlers, you can emit specialized failure events. The aggregator step can then decide whether to proceed with partial data or raise a global exception.
@step
async def retrieve_vector_db(
self, ctx: Context, ev: QueryDecomposedEvent
) -> RetrievalResultEvent | RetrievalFailedEvent:
try:
# Simulate vector database query
chunks = await self.vector_store.aquery(ev.sub_queries[0])
return RetrievalResultEvent(source_name="vector", chunks=chunks)
except Exception as err:
logging.error(f"Vector retrieval failed: {str(err)}")
# Emit a failure event instead of raising to prevent workflow crash
return RetrievalFailedEvent(source_name="vector", error=str(err))
Code Example
import os
import asyncio
import logging
from typing import List, Optional
from pydantic import BaseModel, Field
from llama_index.core.workflow import (
Workflow,
Event,
StartEvent,
StopEvent,
step,
Context
)
# Configure structured logging for workflow execution tracing
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("EventDrivenRAG")
# Define structured data models
class DocumentChunk(BaseModel):
text: str
score: float
source: str
# Define custom workflow events
class QueryDecomposedEvent(Event):
queries: List[str] = Field(..., description="Decomposed sub-queries")
class RetrievalResultEvent(Event):
source_name: str = Field(..., description="Name of the retrieval source")
chunks: List[DocumentChunk] = Field(..., description="Retrieved document chunks")
class EventDrivenRAGWorkflow(Workflow):
"""
An event-driven RAG workflow that executes parallel retrieval paths
and aggregates results before synthesizing a final answer.
"""
@step
async def decompose_query(
self, ctx: Context, ev: StartEvent
) -> QueryDecomposedEvent:
query = ev.get("query")
if not query:
raise ValueError("Query parameter is missing in StartEvent")
logger.info(f"Decomposing query: '{query}'")
# Simulate query decomposition (e.g., splitting into specific search terms)
sub_queries = [f"{query} vector search", f"{query} keyword search"]
return QueryDecomposedEvent(queries=sub_queries)
@step
async def retrieve_vector_store(
self, ctx: Context, ev: QueryDecomposedEvent
) -> RetrievalResultEvent:
query = ev.queries[0]
logger.info(f"Starting Vector Store retrieval for: '{query}'")
# Simulate async network latency of a vector database
await asyncio.sleep(0.15)
# Mocked vector database results
chunks = [
DocumentChunk(text="Vector chunk: LlamaIndex Workflows use event-driven steps.", score=0.92, source="vector_db"),
DocumentChunk(text="Vector chunk: Workflows support parallel execution natively.", score=0.88, source="vector_db")
]
logger.info("Vector Store retrieval completed.")
return RetrievalResultEvent(source_name="vector_store", chunks=chunks)
@step
async def retrieve_bm25_index(
self, ctx: Context, ev: QueryDecomposedEvent
) -> RetrievalResultEvent:
query = ev.queries[1]
logger.info(f"Starting BM25 retrieval for: '{query}'")
# Simulate async network latency of a search index
await asyncio.sleep(0.08)
# Mocked BM25 keyword results
chunks = [
DocumentChunk(text="BM25 chunk: Event-driven RAG improves P99 latency.", score=0.75, source="bm25_index"),
DocumentChunk(text="BM25 chunk: Steps communicate via typed Event objects.", score=0.71, source="bm25_index")
]
logger.info("BM25 retrieval completed.")
return RetrievalResultEvent(source_name="bm25_index", chunks=chunks)
@step
async def synthesize_response(
self, ctx: Context, ev: RetrievalResultEvent
) -> Optional[StopEvent]:
# Collect events from both retrieval steps
events = ctx.collect_events(
ev,
expected_num=2
)
if events is None:
logger.info(f"Received results from '{ev.source_name}'. Waiting for remaining sources...")
return None
logger.info("All retrieval sources received. Aggregating results...")
# Aggregate and sort chunks by score
all_chunks: List[DocumentChunk] = []
for event in events:
all_chunks.extend(event.chunks)
all_chunks.sort(key=lambda x: x.score, reverse=True)
# Format context for synthesis
context_str = "
".join([f"[{c.source}] {c.text}" for c in all_chunks])
# Simulate LLM synthesis
logger.info("Synthesizing final answer with aggregated context...")
await asyncio.sleep(0.1)
synthesis_result = (
f"Based on the retrieved context:
{context_str}
"
f"Answer: LlamaIndex Workflows provide an event-driven framework that "
f"executes retrieval steps in parallel, significantly optimizing latency."
)
return StopEvent(result=synthesis_result)
async def main():
# Verify environment configuration
api_key = os.environ.get("OPENAI_API_KEY", "mock-key-for-testing")
logger.info(f"Initializing workflow run with API key configured: {api_key != 'mock-key-for-testing'}")
# Instantiate and run the workflow
workflow = EventDrivenRAGWorkflow(timeout=10.0)
result = await workflow.run(query="How do LlamaIndex Workflows optimize RAG?")
print("
--- Workflow Output ---")
print(result)
if __name__ == "__main__":
asyncio.run(main())
2026-03-30 12:00:00,000 [INFO] Initializing workflow run with API key configured: False 2026-03-30 12:00:00,002 [INFO] Decomposing query: 'How do LlamaIndex Workflows optimize RAG?' 2026-03-30 12:00:00,003 [INFO] Starting Vector Store retrieval for: 'How do LlamaIndex Workflows optimize RAG? vector search' 2026-03-30 12:00:00,003 [INFO] Starting BM25 retrieval for: 'How do LlamaIndex Workflows optimize RAG? keyword search' 2026-03-30 12:00:00,085 [INFO] BM25 retrieval completed. 2026-03-30 12:00:00,086 [INFO] Received results from 'bm25_index'. Waiting for remaining sources... 2026-03-30 12:00:00,155 [INFO] Vector Store retrieval completed. 2026-03-30 12:00:00,156 [INFO] All retrieval sources received. Aggregating results... 2026-03-30 12:00:00,156 [INFO] Synthesizing final answer with aggregated context... --- Workflow Output --- Based on the retrieved context: [vector_db] Vector chunk: LlamaIndex Workflows use event-driven steps. [vector_db] Vector chunk: Workflows support parallel execution natively. [bm25_index] BM25 chunk: Event-driven RAG improves P99 latency. [bm25_index] BM25 chunk: Steps communicate via typed Event objects. Answer: LlamaIndex Workflows provide an event-driven framework that executes retrieval steps in parallel, significantly optimizing latency.