Índice invertido (inverted index)

A matriz de incidência termo-documento é uma das formas de representar um índice de termos por documento. Mesmo usando o conceito de uma matriz esparsa, essa estrutura pode crescer muito para ser usada em memória. Uma alternativa para esse caso é usar um índice invertido (inverted index).

Dados os seguintes documentos como exemplo:

  • Uma casa à venda em Blumenau
  • Vendo terreno em Gaspar
  • Alugo apartamento em Indaial

A matriz de incidência é:

# matriz de incidência termo-documento
I = [
    #1  2  3    # id do documento
    [0, 0, 1],  # alugo
    [0, 0, 1],  # apartamento
    [1, 0, 0],  # blumenau
    [1, 0, 0],  # casa
    [0, 1, 0],  # gaspar
    [0, 0, 1],  # indaial
    [0, 1, 0],  # terreno
    [1, 1, 0],  # venda
]

O índice invertido é, basicamente, um dicionário. Nesse exemplo, ele será representado em memória, mas a ideia é que as listas de documentos (posting lists) devem ser persistidas em disco e apenas os termos (chaves do dicionário) devem ficar em memória. O exemplo anterior como um índice invertido:

# índice invertido
I = {
    '__alldocs__': [1, 2, 3],
    'alugo': [3],
    'apartamento': [3],
    'blumenau': [1],
    'casa': [1],
    'gaspar': [2],
    'indaial': [3],
    'terreno': [2],
    'venda': [1, 2],
}

As chaves do dicionário são os termos, também chamado de vocabulário. Os valores são listas com os ids dos documentos (postings lists). O detalhe é que, as consultas booleanas agora ficam como operações em conjuntos e não mais como operações bitwise. Também foi necessário adicionar um termo especial __alldocs__ com todos os ids. Esse termo é necessário para processar consultas com NOT.

Exemplos de consultas

# venda AND blumenau
venda = I['venda']
blumenau = I['blumenau']
set(venda) & set(blumenau)  # intersecção de conjuntos

# gaspar OR indaial
gaspar = I['gaspar']
indaial = I['indaial']
set(gaspar) | set(indaial)  # união de conjuntos

# venda AND NOT gaspar
venda = I['venda']
gaspar = I['gaspar']
not_gaspar = set(I['__alldocs__']) - set(I['gaspar'])
set(venda) & not_gaspar

Para mais detalhes de como funcionam as operações com conjuntos veja esse post.

Um exemplo completo de como construir o índice inverso com NLTK e o Brown Corpus.

import string
from itertools import chain
from collections import defaultdict

import nltk
from nltk.corpus import brown
from nltk.corpus import stopwords


# baixa o corpus
nltk.download('brown')

# funções de pré-processamento
STOP_WORDS = set(stopwords.words('english'))


def filtrar_stop_words(doc):
    return (p for p in doc if p not in STOP_WORDS)


def filtrar_pontuacao(doc):
    return (p for p in doc if p not in string.punctuation)


def normalizar_palavras(doc):
    return (p.lower() for p in doc)
    

# paragrafos no formato de lista de frases
# sendo cada frase uma lista de palavras
# o correto nesse caso é apenas uma lista de palavras por paragrafo
DOCUMENTOS = brown.paras()
DOCUMENTOS = [list(chain.from_iterable(p)) for p in DOCUMENTOS]
# faz uma normalização de texto
# deveria ser melhor, mas vai ficar no simples por enquanto
documentos = (normalizar_palavras(p) for p in DOCUMENTOS)
# remove pontuação
documentos = (filtrar_pontuacao(p) for p in documentos)
# remove stop words
documentos = (filtrar_stop_words(p) for p in documentos)
# transforma em uma lista
documentos = [list(p) for p in documentos]

# constrói o índice invertido
inverted_index = defaultdict(list)

for (doc_id, doc) in enumerate(documentos):
    # termo especial para queries com NOT
    inverted_index['__alldocs__'].append(doc_id)
    for termo in doc:
        inverted_index[termo].append(doc_id)

# testes
print('voters AND jury')
voters = inverted_index['voters']
jury = inverted_index['jury']
doc_ids = set(voters) & set(jury)
for doc_id in doc_ids:
    print('> ', ' '.join(DOCUMENTOS[doc_id]))

print('voters OR city')
voters = inverted_index['voters']
city = inverted_index['city']
doc_ids = set(voters) | set(city)
for doc_id in doc_ids:
    print('> ', ' '.join(DOCUMENTOS[doc_id]))
    
print('voters AND NOT city')
voters = inverted_index['voters']
city = inverted_index['city']
not_city = set(inverted_index['__alldocs__']) - set(city)
doc_ids = set(voters) & set(not_city)
for doc_id in doc_ids:
    print('> ', ' '.join(DOCUMENTOS[doc_id]))

Idealmente, o índice invertido deve manter apenas o vocabulário em memória. Os postings (conjunto de todas as postings lists) deve ser armazenado no disco e lido conforme necessidade. Também é importante notar que em alguns casos é possível armazenar o tamanho da posting list, mas nesse caso foi evitado porque não era necessário no momento e com python basta executar len() para obter o valor.

A principal vantagem do índice invertido sobre a matriz de incidência é o uso de memória. Enquanto a matriz precisa ficar em memória, o índice pode ficar parcialmente no disco. Outro detalhe é que o índice invertido guarda apenas os documentos com ocorrência dos termos. Apesar que esse problema foi resolvido usando uma matriz esparsa, que guarda apenas os valores diferentes de 0. Finalmente, é possível estender os algoritmos de processamento de um índice invertido para otimizar a busca ou até mesmo adicionar mais meta dados (como a posição do termo no documento).

Referências