Recuperação de Informação com Concorrência em OTP & Elixir: Dos Fundamentos aos Sistemas de Busca Modernos
Por Matheus de Camargo Marques
Introdução: O Que É Recuperação de Informação?
Recuperação de Informação (Information Retrieval, ou IR) é a ciência de encontrar documentos relevantes dentro de uma coleção massiva de dados não estruturados. Quando você digita uma consulta no Google, quando um sistema de e-commerce busca produtos por palavras-chave, ou quando um juridiquês procura precedentes em milhares de acórdãos — todos esses cenários são aplicações de IR.
Em 2023, escrevi um artigo sobre busca de palavras-chave com concorrência usando OTP & Elixir. Apresentei em uma conferência. A ideia era simples: dividir e conquistar. Divida um texto grande em partes, deixe processos independentes buscarem cada parte em paralelo e agregue os resultados.
Agora, quero expandir aquele trabalho para o domínio mais amplo da Recuperação de Informação. Não se trata mais apenas de contar palavras-chave em um texto. Trata-se de construir sistemas de busca completos: indexar documentos, modelar a relevância, ranquear resultados e avaliar a qualidade da busca.
Neste artigo, vou mostrar como os fundamentos clássicos de IR — modelos booleanos, vetoriais e probabilísticos — se combinam com a concorrência nativa da BEAM para produzir sistemas de busca eficientes e tolerantes a falhas. Tudo com código Elixir funcional.
Parte I: Fundamentos de Recuperação de Informação
1.1 Modelos de Recuperação
A IR clássica se organiza em torno de três modelos fundamentais:
Modelo Booleano. O mais antigo e mais simples. Documentos são representados como conjuntos de termos, e consultas são expressões lógicas (AND, OR, NOT). Um documento é recuperado se satisfaz a expressão booleana. A vantagem é a expressividade; a desvantagem é que não há ranking — ou o documento é relevante, ou não é.
Modelo Vetorial. Cada documento e cada consulta são representados como vetores em um espaço de alta dimensionalidade, onde cada dimensão corresponde a um termo do vocabulário. A relevância é medida pela similaridade entre os vetores — tipicamente a similaridade do cosseno. O modelo vetorial permite ranking, o que o torna mais útil na prática.
Modelo Probabilístico. Em vez de medir "distância" entre vetores, o modelo probabilístico estima a probabilidade de um documento ser relevante para uma consulta. O princípio é o Ranking Probabilístico: ordene documentos pela probabilidade decrescente de relevância. O BM25 (Okapi BM25) é a instância mais bem-sucedida desse modelo.
1.2 Indexação Invertida
O coração de qualquer sistema de busca é o índice invertido. Em vez de mapear documentos para seus termos, o índice invertido mapeia termos para os documentos que os contêm.
A estrutura básica é uma tabela: termo → [lista de documentos]. Em Elixir, podemos representar isso com :ets:
# Estrutura do índice invertido
:ets.new(:index, [:named_table, :public, :set])
:ets.insert(:index, {"elixir", [1, 3, 5]})
:ets.insert(:index, {"otp", [1, 2, 4]})
O Text.IR da biblioteca Text para Elixir implementa exatamente isso: um corpus indexado com scoring TF-IDF e BM25, e busca top-K.
1.3 TF-IDF: A Métrica Clássica
TF-IDF (Term Frequency–Inverse Document Frequency) é a métrica de ponderação mais conhecida em IR. A ideia é simples:
- TF (Term Frequency): quanto mais um termo aparece em um documento, mais relevante ele é para aquele documento.
- IDF (Inverse Document Frequency): quanto mais raro um termo é na coleção, mais poder discriminatório ele tem.
A fórmula é: TF-IDF = TF × IDF. Palavras comuns como "de" ou "a" têm IDF baixo e contribuem pouco. Palavras raras como "recuperação" ou "concorrência" têm IDF alto e são mais valiosas.
1.4 BM25: O Padrão da Indústria
BM25 (Best Matching 25) é uma evolução do TF-IDF que incorpora dois refinamentos importantes:
- Saturação de TF: a relevância de um termo não cresce linearmente com sua frequência. Há um ponto de saturação.
- Normalização por comprimento: documentos longos são penalizados, pois tendem a ter mais termos por acaso.
A fórmula do IDF no BM25 é: idf = ln(1 + (N - df + 0.5)/(df + 0.5)), com parâmetros k1 = 1.2 (saturação de TF) e b = 0.75 (normalização por comprimento).
Estudos comparativos mostram que BM25 supera TF-IDF consistentemente em métricas como Precision@5, MAP e nDCG.
Parte II: Concorrência e Paralelismo em Recuperação de Informação
2.1 Por Que Concorrência?
A IR é computacionalmente intensiva por natureza:
- Indexação: processar milhares ou milhões de documentos, tokenizar, remover stopwords, aplicar stemming, calcular pesos.
- Busca: avaliar consultas contra o índice, calcular scores de relevância, ordenar resultados.
- Avaliação: comparar múltiplos modelos de ranking, calcular métricas para diferentes consultas.
A concorrência permite paralelizar essas tarefas. Em vez de processar documentos sequencialmente, dividimos o corpus em partes e processamos cada parte em um processo independente.
2.2 O Modelo de Atores na BEAM
A BEAM implementa o modelo de atores desde 1986. Cada processo é isolado, com seu próprio heap e garbage collector. A comunicação é feita por passagem de mensagens.
Isso é ideal para IR porque:
- Isolamento: um documento corrompido não afeta o processamento dos outros.
- Escalabilidade: podemos criar milhares de processos leves para processar partes do corpus.
- Tolerância a falhas: se um processo de indexação falhar, o supervisor o reinicia automaticamente.
2.3 Divisão de Trabalho
A estratégia de divisão de trabalho em IR segue o mesmo princípio do meu artigo de 2023:
- Dividir: o corpus é dividido em chunks (por documento, por partição de termos, ou por intervalo de IDs).
- Processar em paralelo: cada chunk é processado por um processo independente.
- Agregar: os resultados parciais são combinados em uma estrutura final.
Em Elixir, isso pode ser feito com Task.async_stream:
# Indexação paralela de documentos
documents
|> Task.async_stream(&index_document/1, max_concurrency: 100)
|> Enum.reduce(%{}, fn {:ok, partial}, acc ->
Map.merge(acc, partial, fn _k, v1, v2 -> v1 ++ v2 end)
end)
Parte III: Implementação em Elixir/OTP
3.1 Estrutura de Módulos
Vamos construir um sistema de IR completo com os seguintes módulos:
-
DocumentProcessor— Tokeniza, remove stopwords e aplica stemming. -
InvertedIndex— Mantém o índice invertido em:ets. -
Ranker— Calcula TF-IDF e BM25. -
SearchServer— GenServer que coordena buscas. -
IndexSupervisor— Supervisor que orquestra a indexação paralela.
3.2 Processamento de Documentos
defmodule DocumentProcessor do
@stopwords ~w(a o e de da do em um uma para com por)
def tokenize(text) do
text
|> String.downcase()
|> String.replace(~r/[^\w\s]/, "")
|> String.split()
|> Enum.reject(&(&1 in @stopwords))
end
def stem(word) do
# Stemming simplificado para inglês
word
|> String.replace_suffix("ing", "")
|> String.replace_suffix("ed", "")
|> String.replace_suffix("s", "")
end
end
3.3 Índice Invertido com ETS
defmodule InvertedIndex do
use GenServer
@table :inverted_index
def start_link(_) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def add_term(term, doc_id) do
GenServer.cast(__MODULE__, {:add, term, doc_id})
end
def lookup(term) do
case :ets.lookup(@table, term) do
[{^term, doc_ids}] -> doc_ids
[] -> []
end
end
@impl true
def init(_) do
:ets.new(@table, [:named_table, :public, :set])
{:ok, %{}}
end
@impl true
def handle_cast({:add, term, doc_id}, state) do
current = lookup(term)
:ets.insert(@table, {term, [doc_id | current]})
{:noreply, state}
end
end
3.4 Ranking com BM25
defmodule Ranker do
@k1 1.2
@b 0.75
def bm25_score(term, doc_id, index, total_docs, avg_doc_len) do
tf = term_frequency(term, doc_id, index)
df = length(InvertedIndex.lookup(term))
idf = :math.log(1 + (total_docs - df + 0.5) / (df + 0.5))
doc_len = doc_length(doc_id, index)
numerator = tf * (@k1 + 1)
denominator = tf + @k1 * (1 - @b + @b * (doc_len / avg_doc_len))
idf * (numerator / denominator)
end
end
3.5 Servidor de Busca com GenServer
defmodule SearchServer do
use GenServer
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def search(query) do
GenServer.call(__MODULE__, {:search, query}, 30_000)
end
@impl true
def handle_call({:search, query}, _from, state) do
terms = DocumentProcessor.tokenize(query)
results =
terms
|> Enum.flat_map(&InvertedIndex.lookup/1)
|> Enum.uniq()
|> Enum.map(fn doc_id ->
score = Enum.reduce(terms, 0, fn term, acc ->
acc + Ranker.bm25_score(term, doc_id, :inverted_index, 1000, 100)
end)
{doc_id, score}
end)
|> Enum.sort_by(fn {_id, score} -> score end, :desc)
{:reply, results, state}
end
end
3.6 Supervisor para Indexação Paralela
defmodule IndexSupervisor do
use Supervisor
def start_link(_), do: Supervisor.start_link(__MODULE__, [], name: __MODULE__)
def index_documents(documents) do
documents
|> Task.async_stream(&index_single/1, max_concurrency: 100)
|> Enum.each(fn {:ok, _} -> :ok end)
end
defp index_single({doc_id, text}) do
terms = DocumentProcessor.tokenize(text)
Enum.each(terms, fn term ->
InvertedIndex.add_term(DocumentProcessor.stem(term), doc_id)
end)
end
@impl true
def init(_) do
children = [
{InvertedIndex, []},
{SearchServer, []}
]
Supervisor.init(children, strategy: :one_for_one, max_restarts: 5)
end
end
Parte IV: Avaliação de Sistemas de IR
4.1 Métricas Fundamentais
A avaliação de IR se baseia em duas métricas centrais:
Precisão (Precision): dos documentos recuperados, quantos são relevantes?
Precision = TP / (TP + FP)Revocação (Recall): dos documentos relevantes, quantos foram recuperados?
Recall = TP / (TP + FN)
O F-score é a média harmônica entre precisão e revocação:
F = 2 × (P × R) / (P + R).
4.2 Métricas de Ranking
Para sistemas que produzem listas ordenadas, métricas adicionais são usadas:
- Precision@K: precisão nos primeiros K resultados.
- MAP (Mean Average Precision): média da precisão média em todos os níveis de recall.
- nDCG (Normalized Discounted Cumulative Gain): pondera a relevância pela posição no ranking.
4.3 Avaliando o Sistema em Elixir
defmodule Evaluator do
def precision(retrieved, relevant) do
tp = MapSet.intersection(retrieved, relevant) |> MapSet.size()
if MapSet.size(retrieved) == 0, do: 0.0, else: tp / MapSet.size(retrieved)
end
def recall(retrieved, relevant) do
tp = MapSet.intersection(retrieved, relevant) |> MapSet.size()
if MapSet.size(relevant) == 0, do: 0.0, else: tp / MapSet.size(relevant)
end
def f_score(retrieved, relevant) do
p = precision(retrieved, relevant)
r = recall(retrieved, relevant)
if p + r == 0, do: 0.0, else: 2 * p * r / (p + r)
end
end
Parte V: Ecossistema Elixir para IR
5.1 Bibliotecas Nativas
O ecossistema Elixir tem crescido significativamente em IR:
- Text.IR: TF-IDF e BM25 com corpus indexado e busca top-K.
- Cercatore: BM25 full-text search com fuzzy matching opcional. Projetado para datasets pequenos e médios, com benchmarks que mostram queries exatas em 0.3ms para 1.000 documentos.
- Elasticlunr: busca full-text com modelo combinado Booleano + TF/IDF + Vetorial.
- TantivyEx: wrapper Elixir para o motor Tantivy (Rust), oferecendo busca de alta performance.
- Torus: integra busca full-text do PostgreSQL diretamente em queries Ecto, com suporte a pattern matching, similarity e text search vectors.
5.2 Abordagens Neurais
O ecossistema também está explorando recuperação neural:
- Stephen: implementa recuperação estilo ColBERT com embeddings por token e scoring MaxSim, rodando nativamente na BEAM. Em vez de comprimir texto em um único vetor, mantém um embedding por token, permitindo matching semântico de granulação fina.
Conclusão: Dos Fundamentos à Prática
Recuperação de Informação é uma disciplina com décadas de pesquisa e refinamento. Os modelos clássicos — booleano, vetorial, probabilístico — continuam sendo a base de sistemas modernos como Elasticsearch e Lucene.
A concorrência nativa da BEAM oferece uma vantagem significativa para IR. A capacidade de criar milhares de processos leves, isolar falhas e recuperar automaticamente torna Elixir/OTP uma escolha natural para sistemas de busca que precisam escalar.
Neste artigo, partimos dos fundamentos de IR — modelos de recuperação, indexação invertida, TF-IDF e BM25 — e construímos um sistema funcional em Elixir com GenServer, Supervisor e ETS. Também exploramos as bibliotecas disponíveis no ecossistema e as métricas para avaliar a qualidade da busca.
O hype em torno de IA e busca semântica é real. Mas os padrões fundamentais da Recuperação de Informação permanecem. Como sempre digo: os padrões nunca morrem. Eles apenas ganham novos disfarces.
Referências
Manning, C. D., Raghavan, P., & Schütze, H. (2008). Introduction to Information Retrieval. Cambridge University Press.
Bookstein, A. (1985). Probability and Fuzzy-Set Applications to Information Retrieval. University of Chicago.
Robertson, S., & Zaragoza, H. (2009). The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval.
Salton, G., Wong, A., & Yang, C. S. (1975). A Vector Space Model for Automatic Indexing. Communications of the ACM.
Text.IR — TF-IDF and BM25 for Elixir. hexdocs.pm/text/Text.IR.html.
Cercatore — BM25 full-text search for Elixir. github.com/joshrotenberg/cercatore.
Elasticlunr — Full-text search library for Elixir. hex.pm/packages/elasticlunr.
TantivyEx — Elixir wrapper for Tantivy. hex.pm/packages/tantivy_ex.
Torus — PostgreSQL search integration for Ecto. hex.pm/packages/torus.
Stephen — ColBERT-style neural retrieval for Elixir. hex.pm/packages/stephen.
Marques, M. C. (2023). Keyword Search with Concurrency in OTP & Elixir.
Elixir official website: elixir-lang.org.
OTP Design Principles: erlang.org/doc/design_principles.
Matheus de Camargo Marques é engenheiro de software com foco em Elixir, Erlang e sistemas distribuídos. Este artigo reflete uma análise pessoal baseada em pesquisa independente e não representa a posição oficial de nenhuma organização.
Top comments (1)
Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support