Current section
Files
Jump to
Current section
Files
lib/arcana/agent/reranker/colbert.ex
defmodule Arcana.Agent.Reranker.ColBERT do
@moduledoc """
ColBERT-style neural reranker using per-token embeddings and MaxSim scoring.
Uses the Stephen library to rerank chunks with fine-grained semantic matching.
Unlike single-vector embeddings, ColBERT maintains one embedding per token,
enabling more nuanced relevance scoring.
## Requirements
Add stephen to your dependencies:
{:stephen, "~> 0.1"}
## Usage
# With Agent pipeline
ctx
|> Agent.search()
|> Agent.rerank(reranker: Arcana.Agent.Reranker.ColBERT)
|> Agent.answer()
# With custom encoder
ctx
|> Agent.search()
|> Agent.rerank(reranker: {Arcana.Agent.Reranker.ColBERT, encoder: my_encoder})
|> Agent.answer()
# Directly
{:ok, reranked} = Arcana.Agent.Reranker.ColBERT.rerank(
"What is Elixir?",
chunks,
threshold: 0.5
)
## Options
* `:encoder` - Pre-loaded Stephen encoder. If not provided, loads the default
encoder on first use (cached for subsequent calls).
* `:threshold` - Minimum score to keep (default: 0.0). ColBERT scores are
typically in the range 0-30+ depending on query/document length.
* `:top_k` - Maximum number of results to return (default: all above threshold)
## Score Interpretation
ColBERT scores are the sum of maximum similarities between query tokens and
document tokens. Higher is better, but the scale depends on query length:
- Short queries (2-3 words): scores typically 5-15
- Medium queries (5-10 words): scores typically 10-25
- Long queries (10+ words): scores typically 20-40+
Consider using `:top_k` rather than `:threshold` for most use cases.
"""
@behaviour Arcana.Agent.Reranker
@default_threshold 0.0
@impl Arcana.Agent.Reranker
def rerank(_question, [], _opts), do: {:ok, []}
def rerank(question, chunks, opts) do
unless Code.ensure_loaded?(Stephen) do
raise """
Stephen is required for ColBERT reranking but not available.
Add it to your dependencies:
{:stephen, "~> 0.1"}
"""
end
encoder = get_encoder(opts)
threshold = Keyword.get(opts, :threshold, @default_threshold)
top_k = Keyword.get(opts, :top_k)
# Build candidates as {id, text} tuples for Stephen
candidates =
chunks
|> Enum.with_index()
|> Enum.map(fn {chunk, idx} -> {to_string(idx), chunk.text} end)
# Rerank using Stephen
results = Stephen.rerank_texts(encoder, question, candidates)
# Map back to chunks with scores
chunks_by_idx =
chunks |> Enum.with_index() |> Map.new(fn {chunk, idx} -> {to_string(idx), chunk} end)
scored_chunks =
results
|> Enum.filter(fn %{score: score} -> score >= threshold end)
|> maybe_take_top_k(top_k)
|> Enum.map(fn %{doc_id: idx, score: score} ->
chunk = Map.fetch!(chunks_by_idx, idx)
Map.put(chunk, :rerank_score, score)
end)
{:ok, scored_chunks}
end
defp get_encoder(opts) do
case Keyword.get(opts, :encoder) do
nil -> get_or_load_default_encoder()
encoder -> encoder
end
end
defp get_or_load_default_encoder do
case :persistent_term.get({__MODULE__, :encoder}, nil) do
nil ->
{:ok, encoder} = Stephen.load_encoder()
:persistent_term.put({__MODULE__, :encoder}, encoder)
encoder
encoder ->
encoder
end
end
defp maybe_take_top_k(results, nil), do: results
defp maybe_take_top_k(results, k), do: Enum.take(results, k)
end