Current section

Files

Jump to
barrel_embed src barrel_embed_colbert.erl
Raw

src/barrel_embed_colbert.erl

%%%-------------------------------------------------------------------
%%% @doc ColBERT late interaction embedding provider
%%%
%%% Uses erlang_python with ColBERT models for multi-vector embeddings. Each
%%% document produces multiple vectors (one per token) for fine-grained matching.
%%%
%%% == Requirements ==
%%% ```
%%% pip install transformers torch
%%% '''
%%%
%%% == Configuration ==
%%% ```
%%% Config = #{
%%% venv => "/path/to/.venv", %% Virtualenv path (recommended)
%%% model => "colbert-ir/colbertv2.0", %% Model name (default, 128 dims)
%%% timeout => 120000 %% Timeout in ms (default)
%%% }.
%%% '''
%%%
%%% When `venv' is specified, the provider uses the venv's Python executable
%%% and properly activates the venv environment.
%%%
%%% == Multi-Vector Format ==
%%% Unlike single-vector embeddings, ColBERT produces a list of vectors:
%%% ```
%%% [[0.1, 0.2, ...], [0.3, 0.4, ...], ...] %% One vector per token
%%% '''
%%%
%%% == Late Interaction ==
%%% ColBERT scoring uses MaxSim:
%%% ```
%%% Score(Q, D) = sum(max(qi ยท dj for all dj in D) for all qi in Q)
%%% '''
%%% This enables fine-grained token-level matching.
%%%
%%% == Supported Models ==
%%% - `"colbert-ir/colbertv2.0"' - Default, 128 dimensions
%%% - `"answerdotai/answerai-colbert-small-v1"' - 96 dimensions, smaller
%%% - `"jinaai/jina-colbert-v2"' - 128 dimensions, long context (8192 tokens)
%%%
%%% == Use Cases ==
%%% - Fine-grained semantic matching
%%% - Passage retrieval with token-level scoring
%%% - Question answering
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(barrel_embed_colbert).
-behaviour(barrel_embed_provider).
%% Behaviour callbacks
-export([
embed/2,
embed_batch/2,
dimension/1,
name/0,
init/1,
available/1
]).
%% Multi-vector API
-export([
embed_multi/2,
embed_batch_multi/2,
maxsim_score/2
]).
-define(DEFAULT_MODEL, "colbert-ir/colbertv2.0").
-define(DEFAULT_TIMEOUT, 120000).
-define(DEFAULT_DIMENSION, 128).
-define(PROVIDER, <<"colbert">>).
%% Multi-vector type: list of token vectors
-type multi_vector() :: [[float()]].
-export_type([multi_vector/0]).
%%====================================================================
%% Behaviour Callbacks
%%====================================================================
%% @doc Provider name.
-spec name() -> atom().
name() -> colbert.
%% @doc Get dimension for this provider.
-spec dimension(map()) -> pos_integer().
dimension(Config) ->
maps:get(dimension, Config, ?DEFAULT_DIMENSION).
%% @doc Initialize the provider.
-spec init(map()) -> {ok, map()} | {error, term()}.
init(Config) ->
Model = maps:get(model, Config, ?DEFAULT_MODEL),
Timeout = maps:get(timeout, Config, ?DEFAULT_TIMEOUT),
Venv = maps:get(venv, Config, undefined),
%% Validate model (warning only)
validate_model(Model),
%% Initialize Python environment
PyConfig = case Venv of
undefined -> #{};
_ -> #{venv => Venv}
end,
case barrel_embed_py:init(PyConfig) of
ok ->
ModelBin = ensure_binary(Model),
case barrel_embed_py:load_model(?PROVIDER, ModelBin) of
{ok, #{dimensions := Dims}} ->
{ok, Config#{
dimension => Dims,
model => ModelBin,
provider => ?PROVIDER,
timeout => Timeout,
initialized => true
}};
{ok, _} ->
%% No dimensions in response, use default
{ok, Config#{
dimension => ?DEFAULT_DIMENSION,
model => ModelBin,
provider => ?PROVIDER,
timeout => Timeout,
initialized => true
}};
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, {init_failed, Reason}}
end.
%% @doc Check if provider is available.
-spec available(map()) -> boolean().
available(#{initialized := true}) ->
true;
available(_Config) ->
false.
%% @doc Generate single-vector embedding (mean pooling of token vectors).
%% Note: For ColBERT, use embed_multi/2 to get full multi-vector output.
-spec embed(binary(), map()) -> {ok, [float()]} | {error, term()}.
embed(Text, Config) ->
case embed_multi(Text, Config) of
{ok, MultiVec} ->
{ok, mean_pool(MultiVec)};
{error, _} = Error ->
Error
end.
%% @doc Generate single-vector embeddings for batch (mean pooling).
-spec embed_batch([binary()], map()) -> {ok, [[float()]]} | {error, term()}.
embed_batch(Texts, Config) ->
case embed_batch_multi(Texts, Config) of
{ok, MultiVecs} ->
{ok, [mean_pool(MV) || MV <- MultiVecs]};
{error, _} = Error ->
Error
end.
%%====================================================================
%% Multi-Vector API
%%====================================================================
%% @doc Generate multi-vector embedding for a single text.
%% Returns a list of token vectors.
-spec embed_multi(binary(), map()) -> {ok, multi_vector()} | {error, term()}.
embed_multi(Text, Config) ->
case embed_batch_multi([Text], Config) of
{ok, [MultiVec]} -> {ok, MultiVec};
{error, _} = Error -> Error
end.
%% @doc Generate multi-vector embeddings for multiple texts.
-spec embed_batch_multi([binary()], map()) -> {ok, [multi_vector()]} | {error, term()}.
embed_batch_multi(Texts, #{model := Model, provider := Provider, initialized := true}) ->
TextsBin = [ensure_binary(T) || T <- Texts],
barrel_embed_py:embed_multi(Provider, Model, TextsBin);
embed_batch_multi(_Texts, _Config) ->
{error, not_initialized}.
%% @doc Calculate MaxSim score between query and document multi-vectors.
%% This is the standard ColBERT scoring function.
%% Score = sum(max(qi ยท dj for all dj in D) for all qi in Q)
-spec maxsim_score(multi_vector(), multi_vector()) -> float().
maxsim_score(QueryVecs, DocVecs) ->
lists:sum([max_dot_product(QVec, DocVecs) || QVec <- QueryVecs]).
%%====================================================================
%% Internal Functions
%%====================================================================
ensure_binary(B) when is_binary(B) -> B;
ensure_binary(L) when is_list(L) -> unicode:characters_to_binary(L).
%% @private
validate_model(Model) ->
ModelBin = ensure_binary(Model),
case is_known_model(ModelBin) of
true -> ok;
false ->
error_logger:warning_msg(
"Model ~s is not in the known list. "
"It may still work if it's a valid ColBERT model.~n",
[ModelBin]
)
end.
%% @private
is_known_model(<<"colbert-ir/colbertv2.0">>) -> true;
is_known_model(<<"answerdotai/answerai-colbert-small-v1">>) -> true;
is_known_model(<<"jinaai/jina-colbert-v2">>) -> true;
is_known_model(_) -> false.
%% @private
%% Mean pooling of token vectors to get single vector
mean_pool([]) -> [];
mean_pool(Vectors) ->
N = length(Vectors),
Dim = length(hd(Vectors)),
%% Sum all vectors element-wise
Sums = lists:foldl(
fun(Vec, Acc) ->
lists:zipwith(fun(A, B) -> A + B end, Vec, Acc)
end,
lists:duplicate(Dim, 0.0),
Vectors
),
%% Divide by N
[S / N || S <- Sums].
%% @private
%% Find maximum dot product between query vector and all doc vectors
max_dot_product(QueryVec, DocVecs) ->
DotProducts = [dot_product(QueryVec, DocVec) || DocVec <- DocVecs],
lists:max(DotProducts).
%% @private
dot_product(V1, V2) ->
lists:sum(lists:zipwith(fun(A, B) -> A * B end, V1, V2)).