Arcana.Agent (Arcana v1.3.3)
View SourcePipeline-based agentic RAG for Arcana.
Compose steps via pipes with a context struct flowing through each transformation:
Arcana.Agent.new(question, llm: llm_fn)
|> Arcana.Agent.search()
|> Arcana.Agent.answer()Context
The Arcana.Agent.Context struct flows through the pipeline, accumulating
results at each step. Each step transforms the context and passes it on.
Steps
new/1,2- Initialize context with question and optionssearch/2- Execute search, populate resultsanswer/1- Generate final answer from results
Configuration
Set defaults in your config to avoid passing options every time:
config :arcana,
repo: MyApp.Repo,
llm: &MyApp.LLM.complete/1Example
ctx =
Arcana.Agent.new("What is Elixir?")
|> Arcana.Agent.search()
|> Arcana.Agent.answer()
ctx.answer
# => "Generated answer"
Summary
Functions
Generates the final answer from search results.
Breaks a complex question into simpler sub-questions.
Expands the query with synonyms and related terms.
Decides whether retrieval is needed for the question.
Creates a new agent context.
Evaluates if search results are sufficient and searches again if not.
Re-ranks search results to improve quality before answering.
Rewrites conversational input into a clear search query.
Executes search and populates results in the context.
Selects which collection(s) to search for the question.
Functions
Generates the final answer from search results.
Collects all chunks from results, deduplicates by ID, and prompts the LLM to generate an answer based on the context.
Options
:answerer- Custom answerer module or function (default:Arcana.Agent.Answerer.LLM):prompt- Custom prompt functionfn question, chunks -> prompt_string end:llm- Override the LLM function for this step:self_correct- Enable self-correcting answers (default: false):max_corrections- Max correction attempts (default: 2)
Example
ctx
|> Agent.search()
|> Agent.answer()
ctx.answer
# => "The answer based on retrieved context..."Custom Answerer
# Module implementing Arcana.Agent.Answerer behaviour
Agent.answer(ctx, answerer: MyApp.TemplateAnswerer)
# Inline function
Agent.answer(ctx, answerer: fn question, chunks, opts ->
llm = Keyword.fetch!(opts, :llm)
prompt = "Q: " <> question <> "Context: " <> inspect(chunks)
Arcana.LLM.complete(llm, prompt, [], [])
end)
Breaks a complex question into simpler sub-questions.
Uses the LLM to analyze the question and split it into parts that can be searched independently. Simple questions are returned unchanged.
Options
:decomposer- Custom decomposer module or function (default:Arcana.Agent.Decomposer.LLM):prompt- Custom prompt functionfn question -> prompt_string end:llm- Override the LLM function for this step
Example
ctx
|> Agent.decompose()
|> Agent.search()
|> Agent.answer()The sub-questions are stored in ctx.sub_questions and used by search/2.
Custom Decomposer
# Module implementing Arcana.Agent.Decomposer behaviour
Agent.decompose(ctx, decomposer: MyApp.KeywordDecomposer)
# Inline function
Agent.decompose(ctx, decomposer: fn question, _opts ->
{:ok, [question]} # No decomposition
end)
Expands the query with synonyms and related terms.
Uses the LLM to add related terms and synonyms that may help
find more relevant documents. The expanded query is used by search/2
if present.
Options
:expander- Custom expander module or function (default:Arcana.Agent.Expander.LLM):prompt- Custom prompt functionfn question -> prompt_string end:llm- Override the LLM function for this step
Example
ctx
|> Agent.expand()
|> Agent.search()
|> Agent.answer()The expanded query is stored in ctx.expanded_query and used by search/2.
Custom Expander
# Module implementing Arcana.Agent.Expander behaviour
Agent.expand(ctx, expander: MyApp.ThesaurusExpander)
# Inline function
Agent.expand(ctx, expander: fn question, _opts ->
{:ok, question <> " programming development"}
end)
Decides whether retrieval is needed for the question.
Uses the LLM to determine if the question can be answered from general knowledge or if it requires searching the knowledge base. Questions about basic facts, math, or general knowledge can skip retrieval.
Sets skip_retrieval: true on the context if retrieval can be skipped,
which causes answer/2 to generate a response without context.
Options
:prompt- Custom prompt functionfn question -> prompt_string end:llm- Override the LLM function for this step
Example
ctx
|> Agent.gate() # Decides if retrieval is needed
|> Agent.search() # Skipped if skip_retrieval is true
|> Agent.answer() # Uses no-context prompt if skip_retrieval
Creates a new agent context.
Options
:repo- The Ecto repo to use (defaults toApplication.get_env(:arcana, :repo)):llm- Function that takes a prompt and returns{:ok, response}or{:error, reason}(defaults toApplication.get_env(:arcana, :llm)):limit- Maximum chunks to retrieve (default: 5):threshold- Minimum similarity threshold (default: 0.5)
Example
# With config defaults
config :arcana, repo: MyApp.Repo, llm: &MyApp.LLM.complete/1
Agent.new("What is Elixir?")
# Or with explicit options
Agent.new("What is Elixir?", repo: MyApp.Repo, llm: &my_llm/1)
Evaluates if search results are sufficient and searches again if not.
This step implements multi-hop reasoning by:
- Asking the LLM if current results can answer the question
- If not, getting a follow-up query and searching again
- Repeating until sufficient or max iterations reached
Tracks queries_tried to prevent searching the same query twice.
Options
:max_iterations- Maximum additional searches (default: 2):prompt- Custom prompt functionfn question, chunks -> prompt_string end:llm- Override the LLM function for this step
Example
ctx
|> Agent.search()
|> Agent.reason() # Multi-hop if needed
|> Agent.rerank()
|> Agent.answer()
Re-ranks search results to improve quality before answering.
Scores each chunk based on relevance to the question, filters by threshold,
and re-sorts by score. Uses Arcana.Reranker.LLM by default.
Options
:reranker- Custom reranker module or function (default:Arcana.Reranker.LLM):threshold- Minimum score to keep (default: 7, range 0-10):prompt- Custom prompt function for LLM rerankerfn question, chunk_text -> prompt end
Example
ctx
|> Agent.search()
|> Agent.rerank()
|> Agent.answer()Custom Reranker
# Module implementing Arcana.Reranker behaviour
Agent.rerank(ctx, reranker: MyApp.CrossEncoderReranker)
# Inline function
Agent.rerank(ctx, reranker: fn question, chunks, opts ->
{:ok, my_rerank(question, chunks)}
end)The reranked results replace ctx.results, and scores are stored in ctx.rerank_scores.
Rewrites conversational input into a clear search query.
Uses the LLM to remove conversational noise (greetings, filler phrases) while preserving the core question and all important terms.
This step should run before expand/2 and decompose/2 to clean up
the input before further transformations.
Options
:rewriter- Custom rewriter module or function (default:Arcana.Agent.Rewriter.LLM):prompt- Custom prompt functionfn question -> prompt_string end:llm- Override the LLM function for this step
Example
ctx
|> Agent.rewrite() # "Hey, tell me about Elixir" → "about Elixir"
|> Agent.expand()
|> Agent.search()
|> Agent.answer()Custom Rewriter
# Module implementing Arcana.Agent.Rewriter behaviour
Agent.rewrite(ctx, rewriter: MyApp.RegexRewriter)
# Inline function
Agent.rewrite(ctx, rewriter: fn question, _opts ->
{:ok, String.downcase(question)}
end)
Executes search and populates results in the context.
Uses sub_questions if present (from decompose step), otherwise uses the original question.
Collection Selection
Collections are determined in this priority order:
:collectionor:collectionsoption passed to this functionctx.collections(set byselect/2if LLM selection was used)- Falls back to
"default"collection
This allows you to explicitly specify a collection without using LLM-based selection:
# Search a specific collection
ctx |> Agent.search(collection: "technical_docs")
# Search multiple specific collections
ctx |> Agent.search(collections: ["docs", "faq"])Options
:searcher- Custom searcher module or function (default:Arcana.Agent.Searcher.Arcana):collection- Single collection name to search (string):collections- List of collection names to search:self_correct- Enable self-correcting search (default: false):max_iterations- Max retry attempts for self-correct (default: 3):sufficient_prompt- Custom prompt functionfn question, chunks -> prompt_string end:rewrite_prompt- Custom prompt functionfn question, chunks -> prompt_string end
Examples
# Basic search (uses default collection)
ctx |> Agent.search() |> Agent.answer()
# Search specific collection
ctx |> Agent.search(collection: "products") |> Agent.answer()
# With pipeline options
ctx
|> Agent.expand()
|> Agent.search(collection: "docs", self_correct: true)
|> Agent.answer()Custom Searcher
# Module implementing Arcana.Agent.Searcher behaviour
Agent.search(ctx, searcher: MyApp.ElasticsearchSearcher)
# Inline function
Agent.search(ctx, searcher: fn question, collection, opts ->
{:ok, my_search(question, collection, opts)}
end)Self-correcting search
When self_correct: true, the agent will:
- Execute the search
- Ask the LLM if results are sufficient
- If not, rewrite the query and retry
- Repeat until sufficient or max_iterations reached
Selects which collection(s) to search for the question.
By default, uses the LLM to decide which collection(s) are most relevant. You can provide a custom selector module or function for deterministic routing.
Collection descriptions are automatically fetched from the database and passed to the selector.
Options
:collections(required) - List of available collection names:selector- Custom selector module or function (default:Arcana.Agent.Selector.LLM):prompt- Custom prompt function for LLM selector:context- User context map passed to custom selectors
Example
# LLM-based selection (default)
ctx
|> Agent.select(collections: ["docs", "api", "support"])
|> Agent.search()
# Custom selector module
ctx
|> Agent.select(
collections: ["docs", "api"],
selector: MyApp.TeamBasedSelector,
context: %{team: user.team}
)
# Inline selector function
ctx
|> Agent.select(
collections: ["docs", "api"],
selector: fn question, _collections, _opts ->
if question =~ "API", do: {:ok, ["api"], "API query"}, else: {:ok, ["docs"], nil}
end
)The selected collections are stored in ctx.collections and used by search/2.