Packages

Text analysis and processing for Elixir including ngram, language detection and more.

Current section

Files

Jump to
text lib ir.ex
Raw

lib/ir.ex

defmodule Text.IR do
@moduledoc """
Information-retrieval scoring against an indexed corpus.
Two scoring functions are provided:
* **TF-IDF** (`tfidf/3`) — the classical term frequency × inverse
document frequency. Useful as a feature for clustering and
classification, and as a fast first-pass relevance signal.
* **BM25** (`bm25/4`) — Okapi BM25, the de-facto standard probabilistic
relevance ranking used by Lucene/Elasticsearch and most modern
search engines. Strictly better than TF-IDF for ranking results to
a query.
Both score functions consume a `Text.IR.Corpus` built once with
`Text.IR.Corpus.new/2`. The corpus precomputes document frequencies,
term frequencies, and document lengths so per-query scoring is
O(query terms × matching documents).
### TF-IDF formula
tf-idf(t, d) = tf(t, d) · log(N / df(t))
where `tf(t, d)` is the count of term `t` in document `d`, `N` is
the total number of documents in the corpus, and `df(t)` is the
number of documents containing `t`. This module uses raw term
frequency (not log-normalised) and the smooth IDF variant
`log((N + 1) / (df + 1)) + 1` so the score is non-negative even
when a query term occurs in every document.
### BM25 formula
score(d, q) = Σ over t in q: idf(t) · (tf · (k1 + 1)) /
(tf + k1 · (1 - b + b · |d|/avgdl))
with the smooth IDF `log((N - df + 0.5) / (df + 0.5) + 1)` (Lucene's
variant — guarantees non-negative IDF) and parameters `k1 = 1.2`,
`b = 0.75` by default.
"""
alias Text.IR.Corpus
@bm25_k1 1.2
@bm25_b 0.75
@doc """
Returns the TF-IDF score for `term` in document `doc_id` of the
given corpus.
### Arguments
* `corpus` is a `Text.IR.Corpus`.
* `doc_id` is the zero-based document index.
* `term` is the term string to score. The term is folded to match
the corpus's case-folding setting before lookup.
### Returns
* A non-negative float. Returns `0.0` when the term doesn't appear
in the document.
### Examples
iex> docs = ["the cat sat", "the dog sat", "a fox ran"]
iex> corpus = Text.IR.Corpus.new(docs)
iex> Text.IR.tfidf(corpus, 0, "cat") > 0.0
true
iex> docs = ["the cat sat", "the dog sat", "a fox ran"]
iex> corpus = Text.IR.Corpus.new(docs)
iex> Text.IR.tfidf(corpus, 0, "missing")
0.0
"""
@spec tfidf(Corpus.t(), Corpus.doc_id(), Corpus.term_string()) :: float()
def tfidf(%Corpus{} = corpus, doc_id, term) do
folded = fold_term(corpus, term)
tf = corpus.term_frequencies |> Map.get(doc_id, %{}) |> Map.get(folded, 0)
if tf == 0 do
0.0
else
tf * smooth_idf(corpus, folded)
end
end
@doc """
Returns the BM25 score for the entire query against document `doc_id`.
### Arguments
* `corpus` is a `Text.IR.Corpus`.
* `doc_id` is the zero-based document index.
* `query` is either a string (which is tokenised through the
corpus's tokenizer) or a list of pre-tokenised terms.
### Options
* `:k1` — saturation parameter, defaults to `1.2`. Higher values
make repeated occurrences of the same term in a document weigh
more heavily.
* `:b` — length-normalisation parameter, defaults to `0.75`.
`0.0` disables length normalisation; `1.0` normalises fully.
### Returns
* A non-negative float. `0.0` if no query term appears in the
document.
### Examples
iex> docs = ["the cat sat on the mat", "the dog sat on the log", "the cat ran"]
iex> corpus = Text.IR.Corpus.new(docs)
iex> Text.IR.bm25(corpus, 0, "cat sat") > Text.IR.bm25(corpus, 1, "cat sat")
true
"""
@spec bm25(Corpus.t(), Corpus.doc_id(), String.t() | [Corpus.term_string()], keyword()) ::
float()
def bm25(%Corpus{} = corpus, doc_id, query, options \\ []) do
k1 = Keyword.get(options, :k1, @bm25_k1)
b = Keyword.get(options, :b, @bm25_b)
query_terms = normalise_query(corpus, query)
doc_tf = Map.get(corpus.term_frequencies, doc_id, %{})
doc_len = Map.get(corpus.doc_lengths, doc_id, 0)
length_factor = 1 - b + b * doc_len / max(corpus.avg_doc_length, 1)
Enum.reduce(query_terms, 0.0, fn term, acc ->
tf = Map.get(doc_tf, term, 0)
if tf == 0 do
acc
else
idf = bm25_idf(corpus, term)
numerator = tf * (k1 + 1)
denominator = tf + k1 * length_factor
acc + idf * numerator / denominator
end
end)
end
@doc """
Returns the top-K documents matching `query`, ranked by score.
### Arguments
* `corpus` is a `Text.IR.Corpus`.
* `query` is a string or pre-tokenised list of terms.
### Options
* `:scorer``:bm25` (default) or `:tfidf`.
* `:k` — number of results to return. Defaults to `10`.
* `:k1`, `:b` — passed through to BM25 when `scorer: :bm25`.
### Returns
* A list of `{doc_id, score}` pairs in descending score order. Documents
with score `0.0` are excluded.
### Examples
iex> docs = [
...> "the cat sat on the mat",
...> "the dog sat on the log",
...> "elephants are large"
...> ]
iex> corpus = Text.IR.Corpus.new(docs)
iex> [{best_id, _score} | _] = Text.IR.search(corpus, "cat", k: 3)
iex> best_id
0
"""
@spec search(Corpus.t(), String.t() | [Corpus.term_string()], keyword()) ::
[{Corpus.doc_id(), float()}]
def search(%Corpus{} = corpus, query, options \\ []) do
scorer = Keyword.get(options, :scorer, :bm25)
k = Keyword.get(options, :k, 10)
query_terms = normalise_query(corpus, query)
score_fn = scorer_function(scorer, corpus, query_terms, options)
0..(corpus.n_docs - 1)
|> Enum.map(fn doc_id -> {doc_id, score_fn.(doc_id)} end)
|> Enum.reject(fn {_id, score} -> score == 0.0 end)
|> Enum.sort_by(fn {_id, score} -> -score end)
|> Enum.take(k)
end
# ---- internal ---------------------------------------------------------
defp scorer_function(:tfidf, corpus, query_terms, _options) do
fn doc_id ->
Enum.reduce(query_terms, 0.0, fn term, acc ->
acc + tfidf(corpus, doc_id, term)
end)
end
end
defp scorer_function(:bm25, corpus, query_terms, options) do
fn doc_id -> bm25(corpus, doc_id, query_terms, options) end
end
defp normalise_query(_corpus, terms) when is_list(terms), do: terms
defp normalise_query(corpus, query) when is_binary(query) do
Corpus.tokenize_query(corpus, query)
end
defp fold_term(%Corpus{fold_case: false}, term), do: term
defp fold_term(%Corpus{fold_case: true}, term), do: String.downcase(term)
# Smooth IDF, non-negative variant: log((N + 1) / (df + 1)) + 1.
defp smooth_idf(%Corpus{} = corpus, term) do
df = Map.get(corpus.document_frequencies, term, 0)
:math.log((corpus.n_docs + 1) / (df + 1)) + 1
end
# Lucene/Elasticsearch-style BM25 IDF: log((N - df + 0.5) / (df + 0.5) + 1).
# The "+ 1" inside the log keeps the result non-negative even when a
# term occurs in more than half the corpus.
defp bm25_idf(%Corpus{} = corpus, term) do
df = Map.get(corpus.document_frequencies, term, 0)
:math.log((corpus.n_docs - df + 0.5) / (df + 0.5) + 1)
end
end