← AI Agents Frameworks & Ecosystem
🤖 AI Agents

LlamaIndex Workflows for Event-Driven RAG

Source: mortalapps.com
TL;DR
  • 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 Event that carry data between steps. Events are strongly typed, allowing Pydantic validation of payloads during step transitions.
  • Step: Methods decorated with @step that accept an event, perform asynchronous operations (such as querying a vector database), and emit new events or return a final result.
  • Context: The Context object 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

A complete, runnable event-driven RAG workflow demonstrating parallel retrieval (Vector and BM25 simulation), event aggregation, and structured synthesis using LlamaIndex Workflows.
Python
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())
Expected Output
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.

Key Takeaways

LlamaIndex Workflows replace rigid DAGs with dynamic, decoupled steps that communicate via typed events.
Parallel execution of retrieval steps significantly reduces P99 latency by running slow I/O operations concurrently.
The Context object in LlamaIndex Workflows is vital for managing state, collecting parallel events, and tracking execution metrics.
Robust error handling and partial aggregation strategies prevent single-point-of-failure bottlenecks in multi-source retrieval.
Strict type-safety using custom Event classes ensures data integrity and simplifies debugging across complex pipelines.
Integrating OpenTelemetry tracing is essential for auditing event-driven architectures in production environments.

Frequently Asked Questions

What is the difference between LlamaIndex Workflows and LangGraph? +
LlamaIndex Workflows are natively event-driven, using async event emission and step handlers, making them highly suitable for dynamic, reactive pipelines. LangGraph is state-machine-centric, where transitions are defined by a structured graph of nodes and edges.
How do I handle a step failure in a parallel retrieval workflow? +
Wrap the step's internal logic in try-except blocks. Instead of letting the exception propagate and crash the workflow, emit a specialized failure event (e.g., `RetrievalFailedEvent`) so the aggregator step can handle it gracefully.
Can I run LlamaIndex Workflows synchronously? +
While workflows are designed for async execution to maximize throughput, you can run them synchronously using `asyncio.run()` or by calling the workflow's synchronous execution entry points if provided by the environment.
How does `ctx.collect_events` work under the hood? +
It buffers incoming events of specified types in the workflow's context. Once all registered event types are present in the buffer, it returns them to the step handler, allowing for map-reduce or aggregation patterns.
When should I avoid using event-driven workflows for RAG? +
Avoid them for simple, linear RAG pipelines where a single query is sent to a single vector index. The architectural overhead of events and async orchestration is unnecessary for basic search-and-synthesize tasks.
How do I prevent infinite loops in cyclic workflows? +
Maintain a step counter inside the `ctx.data` dictionary. Increment this counter at the start of every step, and raise an exception or emit a termination event if the counter exceeds a predefined threshold (e.g., 10 steps).
Is workflow state persisted across restarts? +
By default, LlamaIndex Workflows run in-memory, and state is lost on restart. For persistence, you must implement custom state serialization or integrate with an external database to save and restore the context.
How do I pass initial parameters to a workflow run? +
Pass parameters as arguments to the `run()` method of the workflow. These parameters are packed into the initial `StartEvent` and can be accessed by the first step handler.