Skip to content

Parallel retrieval

Ask several retrievers at once, merge what they return, and stop early when one answer is enough. Every call still goes through the executor's guards, caps, timeouts and ledger.

flowchart LR
  Q["query"] --> K["keyword_search"]
  Q --> V["vector_search"]
  Q --> A["archive_search (slow)"]
  K --> M["merge<br/>dedupe · rank"]
  V --> M
  A -. "cancelled when you stop early" .-> M
  M --> ANS["answer"]

Pick a method

Need Method Returns
Fan out, wait for all, merge run_many(calls, ctx, concurrency=, on_error=) ToolResults in input order
Take results as they finish, stop early as_completed(calls, ctx, concurrency=) async iterator in completion order
Steps that depend on other steps run_plan(steps, ctx) {step.id: ToolResult}
Non-tool work (LLM, embeddings) parallel(*work, timeout=, retries=) values in input order

The retrievers

@registry.tool(kind="search", parallel_safe=True, timeout=5, retries=1)
async def keyword_search(query: str) -> list[str]:
    """Keyword search over the help-center articles."""
    await asyncio.sleep(0.01)
    return INDEX.get(query, [])


@registry.tool(kind="search", parallel_safe=True, timeout=5)
async def vector_search(query: str) -> list[str]:
    """Semantic search over the help-center articles."""
    await asyncio.sleep(0.02)
    return [f"{query} policy", f"{query} similar"]


@registry.tool(parallel_safe=True)
def rerank(hits: list[str], top_k: int = 2) -> list[str]:
    """Order hits by relevance and keep the best."""
    return sorted(set(hits), key=len)[:top_k]


@registry.tool(kind="search", parallel_safe=True)
async def archive_search(query: str) -> list[str]:
    """Slow search over the archive."""
    await asyncio.sleep(30)  # cancelled when the caller stops early
    return []

Mark retrievers parallel_safe=True. A tool without it takes the run's exclusive lock, so the batch runs it alone.

# At most two concurrent "search" calls per run, whatever the batch size.
executor = Executor(registry, limits=RunLimits(per_kind={"search": 2}))

RunLimits(per_kind={"search": 2}) caps concurrent search calls per run, whatever concurrency a batch asks for. Use it to protect a rate-limited backend. For a rate (calls per second) add a RateLimitHook (Hooks).

Fan out, fan in

calls = [
    ToolCall(name="keyword_search", arguments={"query": query}),
    ToolCall(name="vector_search", arguments={"query": query}),
    ToolCall(name="keyword_search", arguments={"query": 7}),  # invalid: not a string
]
results = await executor.run_many(calls, concurrency=8, on_error="collect")
print([r.status for r in results])  # input order; one failure does not stop the rest

merged: dict[str, None] = {}  # fan in: dedupe, keep first-seen order
for result in results:
    if result.ok:
        merged.update(dict.fromkeys(result.value))
return list(merged)
on_error A failing call
"collect" (default) stays in its ToolResult (status="error"); the rest finish
"fail_fast" cancels the siblings; the first error is raised as itself

No batch raises an ExceptionGroup. A batch may not repeat a non-empty call id (ToolUsageError).

Stop at the first good answer

calls = [
    ToolCall(name="archive_search", arguments={"query": query}),
    ToolCall(name="keyword_search", arguments={"query": query}),
]
stream = executor.as_completed(calls)
async with contextlib.aclosing(stream):
    async for result in stream:
        if result.ok and result.value:
            return f"{result.name}: {result.value[0]}"
return "nothing found"

Wrap the stream in contextlib.aclosing. Leaving the block cancels the calls still running: here the 30-second archive_search stops at once. Without aclosing, a break or return leaves them running until the iterator is closed.

Retrieve, then rank

plan = [
    Step(id="kw", tool="keyword_search", args={"query": query}),
    Step(id="vec", tool="vector_search", args={"query": query}),
    Step(
        id="best",
        tool="rerank",  # second wave: runs once both retrievers finished
        args=lambda deps: {"hits": deps["kw"].value + deps["vec"].value},
        depends_on=["kw", "vec"],
    ),
]
by_step = await executor.run_plan(plan)
return list(by_step["best"].value)

The plan runs in waves: kw and vec together, then best. A callable args gets the ToolResults of its own dependencies. A step whose dependency failed does not run: its error kind is dependency-failed. A bad plan (duplicate ids, unknown dependencies, a cycle) raises one PlanInvalid before anything runs.

Non-tool work

async def embed(text: str) -> int:  # stands in for an embedding or LLM call
    await asyncio.sleep(0.01)
    return len(text)

return await executor.parallel(*(lambda t=t: embed(t) for t in texts), timeout=10)

Pass coroutine functions (zero-argument callables) so retries= can start them again; a bare awaitable cannot be retried. Bind loop variables (lambda t=t: ...). A timeout is ToolTimeout(tool="parallel[i]").

Output

['ok', 'ok', 'error']
['returns policy', 'returns label', 'returns similar']
keyword_search: returns policy
['returns label', 'returns policy']
[14, 13]

Tips

Tip Why
Pass one ctx to related batches guards (max_calls, once_per_run) and caps count per run; with no ctx each batch is a fresh run
Give each retriever a timeout one slow backend cannot hold the merge forever
Cache retriever results cache_ttl= plus an executor cache (Caching)
Cap only leaf kinds in per_kind nesting capped kinds both ways can deadlock (Executor policies)

Files

File Does
examples/howto_parallel.py the retrievers and the four patterns
tests/docs/test_howto_examples.py runs it offline and checks the output