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 changekor the filters. filtersis a fixed mapping, orfn(ctx)that computes it per call, for example fromctx.user_id. A blank str value fromfn(ctx)(an unset user id) fails the call withToolUsageErrorbefore 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_ttlcaches results in the executor cache. Per-call filters refusecache_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.