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 (0)