Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Para construir um índice invertido em Elixir, associe cada termo aos documentos em que aparece e guarde a frequência do termo em cada documento. Depois, use essas frequências para gerar candidatos e ordená-los com uma fórmula TF-IDF declarada. A seguir está uma implementação didática em memória, com tratamento de entradas vazias e consultas que não correspondem a nenhum termo.

O que o índice invertido armazena

Uma coleção costuma ser vista como documentos compostos de termos. O índice invertido muda a direção dessa relação: cada termo aponta para os documentos que o contêm. A documentação do Elasticsearch resume: “An inverted index is a data structure that maps each token to the documents that contain it.” (documentação do Elasticsearch).

Para busca booleana, uma lista de IDs pode bastar. Para ordenar resultados por relevância, é útil armazenar também a frequência do termo em cada documento; para busca de frases, podem ser necessárias posições. A documentação do Elasticsearch descreve frequência e posição como metadados possíveis nos postings. Neste exemplo, cada termo aponta para um mapa de ID do documento para frequência:

%{"elixir" => %{1 => 2, 3 => 1}, "busca" => %{2 => 1}}

O mapa externo funciona como dicionário de termos; o mapa interno é a posting list. Como cada ID aparece uma única vez por termo, o número de documentos que contêm o termo é simplesmente o tamanho do mapa interno.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Prepare os documentos e normalize os termos

Uma função de tokenização precisa ser aplicada tanto aos documentos quanto às consultas. Se a normalização diferir entre indexação e busca, um termo poderá existir no índice e ainda assim não ser encontrado pela consulta. Aqui a regra é deliberadamente simples: converter para minúsculas, separar por qualquer sequência que não seja letra ou número Unicode e descartar partes vazias.

defmodule MiniSearch do
  def tokenize(text) when is_binary(text) do
    text
    |> String.downcase()
    |> String.split(~r/[^p{L}p{N}]+/u, trim: true)
  end
end

A expressão usa a opção Unicode da regex. A regra preserva letras acentuadas como parte dos tokens e trata hífens e pontuação como separadores. Isso não resolve todas as decisões linguísticas: stemming, palavras vazias, variantes de acentuação, compostos com hífen e outras convenções de Unicode podem exigir regras próprias conforme os dados e a busca esperada.

Construa o índice em memória

Para cada documento, conte primeiro as ocorrências dos tokens. Em seguida, atualize a posting list de cada termo com a frequência daquele documento. A implementação abaixo começa com um mapa vazio e retorna um mapa vazio para uma coleção vazia.

defmodule MiniSearch do
  def tokenize(text) when is_binary(text) do
    text
    |> String.downcase()
    |> String.split(~r/[^p{L}p{N}]+/u, trim: true)
  end

  def index(documents) when is_list(documents) do
    Enum.reduce(documents, %{}, fn %{id: id, text: text}, index ->
      text
      |> tokenize()
      |> Enum.frequencies()
      |> Enum.reduce(index, fn {term, tf}, acc ->
        postings = Map.get(acc, term, %{})
        Map.put(acc, term, Map.put(postings, id, tf))
      end)
    end)
  end
end

O formato esperado é uma lista como [%{id: 1, text: "Elixir cria ferramentas"}, %{id: 2, text: "Ferramentas para busca"}]. IDs devem ser únicos: se a entrada repetir um ID, a frequência anterior daquele ID para um termo será substituída pela do documento processado depois, não somada. O índice também não conserva documentos sem tokens; eles não contribuem com postings nem com resultados.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A frequência de termo, ou tf, conta ocorrências dentro de um documento. Já a frequência documental, ou df, conta em quantos documentos do corpus o termo aparece. São medidas diferentes: um termo pode repetir muitas vezes em um documento e ainda ocorrer em poucos documentos.

Recupere candidatos e ordene-os por TF-IDF

Recuperação e ranking são etapas distintas. A recuperação determina quais documentos podem ser candidatos; a pontuação atribui peso aos candidatos. Para uma consulta com vários termos, a implementação abaixo usa OR: basta o documento conter pelo menos um termo consultado para ser candidato. As contribuições dos termos presentes são somadas.

A convenção escolhida é tf(t,d) × log(N / df(t)), em que N é o total de documentos de entrada e df(t) é o número de documentos com o termo. A frequência bruta favorece repetição local; o IDF reduz o peso de termos difundidos pelo corpus. Não há suavização nessa fórmula. Um termo desconhecido não tem posting e, portanto, não gera pontuação; com N maior que zero, qualquer termo indexado tem df maior que zero.

defmodule MiniSearch do
  def tokenize(text) when is_binary(text) do
    text
    |> String.downcase()
    |> String.split(~r/[^p{L}p{N}]+/u, trim: true)
  end

  def index(documents) when is_list(documents) do
    Enum.reduce(documents, %{}, fn %{id: id, text: text}, index ->
      text
      |> tokenize()
      |> Enum.frequencies()
      |> Enum.reduce(index, fn {term, tf}, acc ->
        postings = Map.get(acc, term, %{})
        Map.put(acc, term, Map.put(postings, id, tf))
      end)
    end)
  end

  def search(documents, index, query) do
    n = length(documents)
    terms = tokenize(query) |> Enum.uniq()

    scores =
      Enum.reduce(terms, %{}, fn term, acc ->
        case Map.fetch(index, term) do
          {:ok, postings} ->
            df = map_size(postings)
            idf = :math.log(n / df)

            Enum.reduce(postings, acc, fn {id, tf}, scores ->
              Map.update(scores, id, tf * idf, &(&1 + tf * idf))
            end)

          :error ->
            acc
        end
      end)

    by_id = Map.new(documents, fn %{id: id} = document -> {id, document} end)

    scores
    |> Enum.map(fn {id, score} -> {Map.fetch!(by_id, id), score} end)
    |> Enum.sort_by(fn {_document, score} -> score end, :desc)
  end
end

A consulta sem tokens, ou composta apenas por termos desconhecidos, produz uma lista vazia. O mesmo ocorre com uma coleção vazia. Se um termo aparecer em todos os documentos, seu IDF será zero nesta fórmula; ele não altera a ordem por si só. Empates não têm um critério secundário definido aqui. A pontuação não normaliza pelo comprimento do documento.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Confira os resultados à mão

Considere três documentos: ID 1 com “elixir elixir busca”, ID 2 com “elixir busca busca” e ID 3 com “elixir”. Para a consulta “elixir busca”, OR recupera os três. elixir aparece nos três documentos, então df=3 e log(3/3)=0; busca aparece em dois, então df=2 e seu peso IDF é log(3/2). A pontuação do ID 1 é 1 × log(3/2); a do ID 2 é 2 × log(3/2); a do ID 3 é zero. Assim, ID 2 fica à frente de ID 1, e o ID 3 permanece candidato com pontuação zero.

Para “fantasma”, não há posting e a busca retorna lista vazia. Isso é diferente de um termo comum que esteja no índice, mas tenha IDF zero: nesse caso há candidatos, ainda que a contribuição seja zero.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

O que a fórmula ensina — e o que não promete

TF-IDF é uma etapa pedagógica clara, não uma fórmula única. A variante acima usa frequência bruta, logaritmo natural e nenhum smoothing ou ajuste por comprimento. Outras convenções transformam a frequência, suavizam o IDF e normalizam documentos. A API TFIDFSimilarity do Apache Lucene 7.2.0 documenta, nessa versão, uma variante que inclui TF por raiz quadrada, IDF suavizado baseado em docCount e docFreq, além de fator de normalização de comprimento (API TFIDFSimilarity 7.2.0). Essa versão é um exemplo concreto de escolhas possíveis, não uma declaração sobre a fórmula padrão das versões atuais do Lucene.

O Elasticsearch informa que BM25 é seu modelo de relevância padrão e o descreve como uma variação de TF-IDF (documentação de similaridade do Elasticsearch). Em termos gerais, BM25 satura o ganho de frequência em vez de aumentar linearmente sem limite e incorpora normalização por comprimento; parâmetros como k1 e b controlam esses comportamentos. A configuração e a versão efetivamente usadas podem alterar o comportamento. Para este índice didático, a frequência é linear, não há normalização de comprimento e TF-IDF foi escolhido pela transparência, não por ser automaticamente a escolha de produção.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Quando usar Enum e quando considerar Stream

Para a coleção pequena em memória deste exemplo, Enum deixa explícito cada passo e consome imediatamente os enumeráveis. Elixir também oferece Stream para pipelines preguiçosos, úteis quando as etapas podem ser processadas sob demanda em coleções maiores. A documentação da linguagem trata ainda de redução e suspensão de enumeráveis e de pipelines associados a recursos (documentação de Enum e Stream no branch principal do Elixir).

Trocar simplesmente uma chamada por Stream não torna todo o índice limitado em memória: o mapa invertido final continua crescendo conforme os termos e documentos indexados. Se a origem forem arquivos ou outro recurso, prefira APIs que cuidem do encerramento do recurso; e use streaming quando ele evitar materializar etapas intermediárias desnecessárias.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.