Current section
Files
Jump to
Current section
Files
lib/arcana/ask.ex
defmodule Arcana.Ask do
@moduledoc """
RAG (Retrieval Augmented Generation) question answering.
This module handles the core ask workflow:
1. Search for relevant context chunks
2. Build a prompt with the context
3. Call the LLM for an answer
## Usage
{:ok, answer, context} = Arcana.ask("What is X?",
repo: MyApp.Repo,
llm: "openai:gpt-4o-mini"
)
"""
alias Arcana.LLM
@doc """
Asks a question using retrieved context from the knowledge base.
Performs a search to find relevant chunks, then passes them along with
the question to an LLM for answer generation.
## Options
* `:repo` - The Ecto repo to use (required)
* `:llm` - Any type implementing the `Arcana.LLM` protocol (required)
* `:limit` - Maximum number of context chunks to retrieve (default: 5)
* `:source_id` - Filter context to a specific source
* `:threshold` - Minimum similarity score for context (default: 0.0)
* `:mode` - Search mode: `:vector` (default), `:keyword`, or `:hybrid`.
`:semantic` and `:fulltext` are deprecated aliases and log a warning.
* `:collection` - Filter to a specific collection
* `:collections` - Filter to multiple collections
* `:prompt` - Custom prompt function. Supports arity 2 `(question, context)` or
arity 3 `(question, context, graph_context)`
* `:reranker` - Reranker module/function (passed through to search)
* `:rewriter` - Query rewriter (passed through to search)
* `:graph` - Enable/disable GraphRAG (default: global config)
Defaults for `:limit` can be set globally:
config :arcana, ask: [limit: 5]
## Examples
# Basic usage
{:ok, answer, context} = Arcana.ask("What is Elixir?",
repo: MyApp.Repo,
llm: "openai:gpt-4o-mini"
)
# With custom prompt
{:ok, answer, _} = Arcana.ask("Summarize the docs",
repo: MyApp.Repo,
llm: my_llm,
prompt: fn question, context ->
"Be concise. Question: \#{question}"
end
)
"""
def ask(question, opts) when is_binary(question) do
opts = Arcana.Config.merge_app_opts(opts, :ask)
repo = Arcana.Config.get(opts, :repo)
llm = Arcana.Config.get(opts, :llm)
if is_nil(llm), do: {:error, :no_llm_configured}, else: do_ask(question, opts, repo, llm)
end
defp do_ask(question, opts, repo, llm) do
start_metadata = %{question: question, repo: repo}
:telemetry.span([:arcana, :ask], start_metadata, fn ->
# Forward everything except ask-specific keys so backend tuning flows through
search_opts =
opts
|> Keyword.drop([:llm, :prompt])
|> Keyword.put_new(:limit, 5)
case Arcana.Search.search(question, search_opts) do
{:ok, context} -> ask_with_context(question, context, opts, llm)
{:error, reason} -> {{:error, {:search_failed, reason}}, %{error: reason}}
end
end)
end
defp ask_with_context(question, context, opts, llm) do
graph_context = maybe_fetch_graph_context(question, opts)
prompt_fn = Keyword.get(opts, :prompt, &default_ask_prompt/2)
llm_opts = [
system_prompt:
case Function.info(prompt_fn, :arity) do
{:arity, 3} -> prompt_fn.(question, context, graph_context)
{:arity, _} -> prompt_fn.(question, context)
end
]
result =
case LLM.complete(llm, question, context, llm_opts) do
{:ok, answer} -> {:ok, answer, context}
{:error, reason} -> {:error, reason}
end
stop_metadata =
case result do
{:ok, answer, _} -> %{answer: answer, context_count: length(context)}
{:error, _} -> %{context_count: length(context)}
end
{result, stop_metadata}
end
defp default_ask_prompt(question, context),
do: default_ask_prompt(question, context, %{})
defp default_ask_prompt(_question, context, graph_context) when is_map(graph_context) do
context_text =
Enum.map_join(context, "\n\n---\n\n", fn
%{text: text} -> text
text when is_binary(text) -> text
other -> inspect(other)
end)
graph_sections = format_graph_sections(graph_context)
if context_text != "" do
"""
Answer the user's question based on the following context.
If the answer is not in the context, say you don't know.
#{graph_sections}
Source passages:
#{context_text}
"""
else
"You are a helpful assistant."
end
end
# Backward compat: list of community summaries
defp default_ask_prompt(question, context, community_summaries)
when is_list(community_summaries) do
default_ask_prompt(question, context, %{community_summaries: community_summaries})
end
defp format_graph_sections(%{} = ctx) do
sections = []
sections =
case Map.get(ctx, :entities, []) do
[] ->
sections
entities ->
entity_text =
Enum.map_join(entities, "\n", fn e ->
desc = if e[:description], do: ": #{e.description}", else: ""
"- #{e.name} (#{e.type})#{desc}"
end)
sections ++ ["\nRelevant entities:\n#{entity_text}"]
end
sections =
case Map.get(ctx, :relationships, []) do
[] ->
sections
rels ->
rel_text =
Enum.map_join(rels, "\n", fn r ->
"- #{r.source} --[#{r.type}]--> #{r.target}"
end)
sections ++ ["\nRelationships:\n#{rel_text}"]
end
sections =
case Map.get(ctx, :community_summaries, []) do
[] ->
sections
summaries ->
text = Enum.map_join(summaries, "\n\n", & &1)
sections ++ ["\nBackground knowledge:\n#{text}"]
end
Enum.join(sections, "\n")
end
defp maybe_fetch_graph_context(question, opts) do
repo = Arcana.Config.get(opts, :repo)
if Arcana.Config.graph_enabled?(opts) and repo do
fetch_graph_context(question, repo, opts)
else
%{}
end
end
defp fetch_graph_context(question, repo, opts) do
import Ecto.Query
alias Arcana.Graph.{Community, Entity, GraphStore, Relationship}
graph_config = Arcana.Graph.config()
entity_limit = graph_config[:context_entity_limit] || 10
rel_limit = graph_config[:context_relationship_limit] || 20
summary_level = graph_config[:community_summary_level] || 0
summary_limit = graph_config[:community_summary_limit] || 5
threshold = graph_config[:entity_embedding_threshold] || 0.3
collection_ids = resolve_collection_ids(opts, repo)
embedder = Arcana.Config.embedder()
matched_entities =
case Arcana.Embedder.embed(embedder, question, intent: :query) do
{:ok, query_embedding} ->
GraphStore.search_by_embedding(query_embedding, collection_ids,
repo: repo,
limit: entity_limit,
threshold: threshold
)
_ ->
[]
end
if matched_entities == [] do
%{}
else
entity_ids = Enum.map(matched_entities, & &1.id)
relationships =
repo.all(
from(r in Relationship,
join: src in Entity,
on: r.source_id == src.id,
join: tgt in Entity,
on: r.target_id == tgt.id,
where: r.source_id in ^entity_ids and r.target_id in ^entity_ids,
select: %{source: src.name, target: tgt.name, type: r.type},
limit: ^rel_limit
)
)
community_summaries =
repo.all(
from(c in Community,
where:
fragment("? && ?", c.entity_ids, ^entity_ids_to_binary(entity_ids)) and
not is_nil(c.summary) and c.summary != "" and
c.level == ^summary_level,
select: c.summary,
limit: ^summary_limit
)
)
%{
entities: matched_entities,
relationships: relationships,
community_summaries: community_summaries
}
end
end
defp entity_ids_to_binary(entity_ids) do
Enum.map(entity_ids, fn id ->
{:ok, bin} = Ecto.UUID.dump(id)
bin
end)
end
defp resolve_collection_ids(opts, repo) do
case Arcana.Collection.names_from_opts(opts) |> Arcana.Collection.resolve_ids(repo) do
nil -> nil
[] -> nil
ids -> ids
end
end
end