Skip to content

Retrieval (RAG)

agent_adapter.retrieval lets a model search your documents and answer with citations. You bring the search (a vector database, a search API, LlamaIndex, LangChain, or a small in-memory corpus). The adapter adds input checks, filter enforcement, typed errors, trace events and a registry tool that runs through the executor like any other.

flowchart LR
  M["model: search_docs(query)"] --> E["Executor<br/>hooks · budgets · cache"]
  E --> T["retriever_tool<br/>k and filters set by the host"]
  T --> G["GuardedRetriever<br/>check query, k, filters"]
  G --> B["your backend<br/>Callable · Embedding ·<br/>LlamaIndex · LangChain"]
  B --> R["re-check: drop repeats and<br/>documents outside the filters"]
  R --> D["Documents<br/>id · text · score · source"]
  R --> V["retrieval.searched<br/>counts and ids"]
  D --> M
File Does
retrieval/port.py RetrieverPort: retrieve(query, *, k, filters, ctx) -> list[Document]
retrieval/documents.py Document, Source, Filters and the shared checks
retrieval/_base.py GuardedRetriever: checks, filter enforcement, events, retrieve_sync
retrieval/callable.py CallableRetriever: any search function
retrieval/embedding.py EmbeddingRetriever: in-memory cosine search over an EmbeddingPort
retrieval/llamaindex.py LlamaIndexRetriever: a LlamaIndex index or retriever
retrieval/langchain.py LangChainRetriever: a LangChain retriever
retrieval/tool.py retriever_tool: a retriever as a registry tool

A query enters through retrieve() or the tool. It leaves only to your backend. Documents leave to the caller (the model, through the tool). Events carry counts and document ids; the query and texts are added only with capture_content=True.

A corpus

def page(id: str, text: str, museum: str) -> Document:
    """A help page of one museum; `museum` is the scope a search is limited to."""
    source = Source(uri=f"https://help.example.com/{museum}/{id}", title=id.replace("-", " "))
    return Document(id=f"{museum}-{id}", text=text, source=source, metadata={"museum": museum})


PAGES = [
    page("refunds", "A ticket refund is possible up to 24 hours before the visit.", "city"),
    page("opening-hours", "Opening hours are 9 to 17, closed on Mondays.", "city"),
    page("audio-guide", "The audio guide is free with every ticket.", "city"),
    page("refunds", "Tickets bought online get no refund.", "harbour"),
]
retriever = EmbeddingRetriever(CallableEmbedder(embed), PAGES, scope_key="museum")

A Document has an id, its text, a score (higher is better, or None), a source (uri and title, for citations) and free metadata. Backends that return no id get a stable one, a digest of the source and text.

EmbeddingRetriever(embedder, documents=(), *, scope_key=None) embeds the documents on first use and ranks by cosine similarity. It is for demos, tests and small corpora (up to 50,000 documents). Use add() / add_sync() to grow it.

The tool

registry = Registry()
registry.add(
    retriever_tool(
        retriever,
        k=2,
        filters=lambda ctx: {"museum": ctx.user_id},  # the host's scope, never the model's
        cache_ttl=300,
    )
)

retriever_tool(retriever, *, name="search_docs", description=..., k=5, filters=None, include_metadata=False, max_chars=None, cache_ttl=None, cache_scope="run", timeout=None, tags=()) returns a ToolSpec of kind "retrieval".

  • The model sees one argument, query. It cannot change k or the filters.
  • filters is a fixed mapping, or fn(ctx) that computes it per call, for example from ctx.user_id. A blank str value from fn(ctx) (an unset user id) fails the call with ToolUsageError before the backend runs.
  • The tool returns {"documents": [{"id", "text", "score", "source"}, ...]}. The default description tells the model to answer only from them and to cite the source.
  • cache_ttl caches results in the executor cache. Per-call filters refuse cache_scope="global", since the cache key holds only the query.

In a tool loop

sink = EventLog()
executor = Executor(registry, sink=sink, cache=MemoryCache())
model = CallableModel(scripted_llm, provider="host", model="help-desk-v1")
question = (Message(role="user", content="Can I get a ticket refund?"),)
for museum in ("city", "harbour"):
    ctx = dataclasses.replace(executor.context(), user_id=museum)
    result = await run_tool_loop(model, registry, question, executor=executor, ctx=ctx)
    print(museum, "->", result.final.text)
[first, second] = sink.of_type("retrieval.searched")
print(first.data["ids"], second.data["ids"])

Each search emits retrieval.searched inside the tool call, with retriever, backend, k, count, ids, dropped and latency_ms. A failure emits retrieval.failed with error_kind.

Any search service

def search_service(query: str, k: int, filters: dict[str, object]) -> list[dict[str, object]]:
    """Stands in for a vector database or search API client."""
    hits = [{"id": "faq-7", "text": "Parking is free after 17:00.", "metadata": filters}]
    return hits[:k]

faq = CallableRetriever(search_service, name="faq")
[hit] = await faq.retrieve("parking", k=3, filters={"museum": "city"})
print(hit.id, hit.text)

CallableRetriever(fn, *, name="", timeout_s=None, capture_content=False) calls fn(query, k, filters), sync (in a worker thread) or async. It may return Documents, strings, or mappings with text (plus id, score, source, metadata).

LlamaIndex and LangChain

Adapter Calls k and filters
LlamaIndexRetriever(index) index.as_retriever(similarity_top_k=k, filters=...) native: MetadataFilters, or your native_filters(filters)
LlamaIndexRetriever(retriever) aretrieve(query) or retrieve(query) the retriever's own; results re-checked
LangChainRetriever(retriever) ainvoke(query, **search_kwargs(k, filters)) none by default; pass search_kwargs

Install the framework with pip install "agent-adapter[llamaindex]" or "agent-adapter[langchain]". A vector-store retriever in LangChain takes search_kwargs=lambda k, f: {"k": k, "filter": f}.

Scope and tenant isolation

Filters are exact matches on metadata (str, int, float or bool; a bool matches only a bool). Every result is re-checked: a document whose metadata does not match is dropped and a warning is logged. A backend that ignores filters returns fewer results, never another scope's.

Isolation itself is the backend's job: use its own filters, namespaces or collections per tenant, so the right documents reach the top k. The exception is EmbeddingRetriever(..., scope_key="museum"): every document needs a non-empty str under that metadata key, and every search must filter on one, or it raises ToolUsageError.

Errors

Error When
ToolUsageError a bad query (empty, over 8,000 characters), k (1 to 100) or filters; a result that is not a document; an unscoped search on a scoped corpus
IntegrationError the backend raised or ran past timeout_s; the cause is chained, the log holds only its type
ConfigError bad options; an embedder whose vector size changed
MissingDependency LlamaIndex filters without llama-index-core

Sync code

retriever.retrieve_sync(query, k=5, filters=None, timeout=None) and EmbeddingRetriever.add_sync(documents) block on the process-wide loop the executor's sync methods use.