Current section
Files
Jump to
Current section
Files
lib/word_cloud/backends/key_bert.ex
defmodule Text.WordCloud.Backends.KeyBERT do
@moduledoc """
Neural keyword-extraction backend backed by
[Bumblebee](https://hex.pm/packages/bumblebee).
Implements [KeyBERT](https://github.com/MaartenGr/KeyBERT)-style
scoring: embed the input document and each candidate phrase with a
multilingual sentence-transformer, then rank candidates by cosine
similarity to the document embedding. The intuition is that the
best keyword candidates are the phrases whose meaning is closest
to the document as a whole — exactly what neural sentence
embeddings capture.
This backend is **opt-in**:
* The `:bumblebee` and (recommended) `:exla` Hex packages must be
declared as dependencies of the host application.
* Either pass `scoring: :key_bert` to `Text.WordCloud.terms/2`,
or use this module directly.
### Cold start
The first call downloads the default model (~470 MB —
[`sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2`](https://huggingface.co/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2),
multilingual across ~50 languages) from Hugging Face, traces the
inference graph, and compiles it under EXLA. Subsequent calls hit a
cached `Nx.Serving` in `:persistent_term`. Pre-download via
`mix text.download_models --keybert` if your production environment
needs everything present at boot.
### When to use this backend
KeyBERT typically produces the highest-quality output of any
backend in this package — at the cost of a model download, GPU/EXLA
compilation, and substantially higher per-call latency than YAKE!
Use this when quality matters more than throughput, or when YAKE!'s
statistical features struggle with very short or very domain-specific
text.
### Options
* `:model` — Hugging Face model id. Defaults to
`"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"`.
Any sentence-transformer model compatible with
`Bumblebee.Text.text_embedding/3` works.
* `:tokenizer_repo` — overrides the tokenizer source repo (rarely
needed for sentence-transformer models, which ship complete
tokenizers).
* `:serving` — name or pid of a pre-started `Nx.Serving` to skip
the lazy `:persistent_term` cache. Recommended for production.
* `:candidate_pool_size` — cap on the number of candidates
embedded; large documents can produce hundreds of phrases and
embedding all of them is wasteful. Defaults to `200`. Candidates
are pre-filtered by raw frequency before embedding.
* `:ngram_range` — `{min, max}` candidate length. Defaults to
`{1, 3}`.
Standard `Text.WordCloud` orchestrator options (`:language`,
`:stopwords`, `:case_fold`, `:locale`) are honoured.
"""
# `Bumblebee` is an optional dependency. The references below live
# inside `if Code.ensure_loaded?(Bumblebee) do` blocks, but the
# compiler still walks the AST and warns about undefined modules
# before the conditional is evaluated.
@compile {:no_warn_undefined, [Bumblebee, Bumblebee.Text, EXLA, Nx.Serving]}
@default_model "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
if Code.ensure_loaded?(Bumblebee) do
@behaviour Text.WordCloud.Backend
alias Text.WordCloud.Tokens
@default_pool 200
@impl true
def score(input, options) do
{min_n, max_n} = Keyword.get(options, :ngram_range, {1, 3})
pool_size = Keyword.get(options, :candidate_pool_size, @default_pool)
%{sentences: sentences} = Tokens.prepare(input, options)
# Build the candidate pool: top-N by frequency, to keep the
# number of model calls bounded.
candidates =
sentences
|> Tokens.candidates(min_n, max_n)
|> Tokens.count_candidates()
|> Enum.sort_by(fn {_t, count, _kind} -> -count end)
|> Enum.take(pool_size)
if candidates == [] do
[]
else
score_candidates(input_text(input), candidates, options)
end
end
defp score_candidates(text, candidates, options) do
serving = resolve_serving(options)
doc_embedding = embed!(serving, [text]) |> hd()
candidate_texts = Enum.map(candidates, fn {tokens, _c, _k} -> Enum.join(tokens, " ") end)
candidate_embeddings = embed!(serving, candidate_texts)
candidates
|> Enum.zip(candidate_embeddings)
|> Enum.map(fn {{tokens, count, kind}, embedding} ->
score = cosine_similarity(doc_embedding, embedding)
# Cosine sim is in [-1, 1]; shift to [0, 2] so the orchestrator's
# max-normalisation produces sensible relative weights even when
# the top candidate has only modest similarity.
{Enum.join(tokens, " "), score + 1.0, count, kind}
end)
end
# ---- internal: serving cache --------------------------------------
defp resolve_serving(options) do
case Keyword.get(options, :serving) do
nil ->
model = Keyword.get(options, :model, @default_model)
cache_key = {__MODULE__, :serving, model}
case :persistent_term.get(cache_key, nil) do
nil ->
serving = build_serving(model, options)
:persistent_term.put(cache_key, serving)
serving
serving ->
serving
end
serving ->
serving
end
end
defp build_serving(model, options) do
defn_options = Keyword.get(options, :defn_options, default_defn_options())
compile = Keyword.get(options, :compile, batch_size: 16, sequence_length: 128)
tokenizer_repo = Keyword.get(options, :tokenizer_repo, model)
{:ok, model_info} = Bumblebee.load_model({:hf, model})
{:ok, tokenizer} = Bumblebee.load_tokenizer({:hf, tokenizer_repo})
Bumblebee.Text.text_embedding(model_info, tokenizer,
compile: compile,
defn_options: defn_options,
output_attribute: :hidden_state,
output_pool: :mean_pooling
)
end
defp default_defn_options do
if Code.ensure_loaded?(EXLA), do: [compiler: EXLA], else: []
end
# ---- internal: embedding + similarity -----------------------------
defp embed!(serving, texts) when is_list(texts) do
texts
|> Enum.map(fn text -> Nx.Serving.run(serving, text).embedding end)
end
defp cosine_similarity(a, b) do
dot = a |> Nx.dot(b) |> Nx.to_number()
norm_a = a |> Nx.pow(2) |> Nx.sum() |> Nx.sqrt() |> Nx.to_number()
norm_b = b |> Nx.pow(2) |> Nx.sum() |> Nx.sqrt() |> Nx.to_number()
denom = norm_a * norm_b
if denom == 0.0, do: 0.0, else: dot / denom
end
defp input_text(text) when is_binary(text), do: text
defp input_text([_ | _] = docs), do: Enum.join(docs, "\n")
@doc """
Drops the cached `Nx.Serving` for the given KeyBERT model.
### Arguments
* `model` — a model id string. Defaults to the package default.
Pass `:all` to drop every cached serving for this backend.
### Returns
* `:ok`.
"""
@spec reset(String.t() | :all) :: :ok
def reset(model \\ @default_model)
def reset(:all) do
:persistent_term.get()
|> Enum.each(fn
{{__MODULE__, :serving, _model}, _value} = entry ->
:persistent_term.erase(elem(entry, 0))
_ ->
:ok
end)
end
def reset(model) when is_binary(model) do
_ = :persistent_term.erase({__MODULE__, :serving, model})
:ok
end
else
def score(_input, _options) do
raise """
Text.WordCloud.Backends.KeyBERT requires the :bumblebee dependency
to load the default model #{@default_model}.
Add to your mix.exs:
{:bumblebee, "~> 0.6"},
{:exla, "~> 0.9"}
Then run `mix deps.get`. See the Text.WordCloud.Backends.KeyBERT
moduledoc for usage.
"""
end
end
end