Desde a versão 2.4 do MongoDB existe a possibilidade de realizar uma busca textual nos campos corretamente indexados. Desde então, a forma como o MongoDB trabalha com este tipo de índice evoluiu, trazendo algumas facilidades para os desenvolvedores.

A idéia deste artigo é exemplificar como utilizar este tipo de busca e passar por algumas de suas configurações. Também abordaremos o suporte oferecido, assim como uma análise breve de stemming e stop-words e algumas limitações deste tipo de abordagem.

O que é full-text search?

Também chamada de busca textual, essa abordagem visa encontrar em uma base de textos, um match para o resultado buscado. A idéia é encontrar uma palavra ou frase que o usuário deseja, em meio a uma grande quantidade de documentos.

Com a evolução deste tipo de busca, foram introduzidas formas de encontrar o dado desejado por aproximação (stemming) e também de ignorar palavras que teriam match  num número muito grande de documentos, sendo consideradas palavras-coringa (stop-words).

Stemming

Para que ocorra a aproximação do que está sendo buscado com os possíveis candidatos a resultados, as ferramentas que realizam o full-text-search implementam a técnica de  stemming. O MongoDB é uma destas ferramentas.

O stemming consiste em uma análise das palavras, em seus respectivos idiomas, para tentar reduzi-la a uma raíz, que pode ser utilizada para a aquisição de resultados similares.

Para mais fácil entendimento, segue um exemplo:

PalavraStem
PedroPedr
PedraPedr
EnsaiarEnsai
EnsaioEnsai

Stop-words

Para evitar resultados indesejados, algumas ferramentas (MongoDB incluso) fazem o uso de uma lista de palavras indesejadas por idioma. Palavras como por, para, de, entre outras são stop-words.

Mãos a massa

Considerando que você já tem uma conexão com o Mongo (aqui, apenas subi um container docker com o MongoDB 4.2 e conectei diretamente no shell), vamos criar uma database e inserir dados em uma collection:

> use redspark // criando uma nova database
> db.jobs.insert([
  { message: 'Solicitação de desenvolvimento de aplicativo mobile', author: 'joão' },
  { message: 'Solicitação de desenvolvimento de front-end', author: 'Joao' },
  { message: 'Solicitação de desenvolvimento de back-end', author: 'Pedro' }
])

E vamos criar nosso índice textual, indexando apenas o o autor, por enquanto:

> db.jobs.createIndex({ author: 'text' }, { default_language: 'pt' })

 O primeiro parâmetro é um objeto contendo o campo a ser indexado (author) e o segundo parâmetro é um objeto de configuração. Neste caso, estamos passando pt, pois queremos que o índice utilize stemming e stop-words no idioma português. Podemos informar a forma extensa também, enviando o parâmetro portuguese.

Além de inglês e português, o MongoDB aceita outros idiomas para full-text search, que podem ser conferidos aqui.

Realizando a primeira busca

Vamos agora buscar pelo nome João. A sintaxe básica para busca textual é assim:

> db.jobs.find({ $text: { $search: 'joao' } })

Vemos que recuperamos 2 documentos com esta busca:

{ "_id" : ObjectId("5e582d2ad5cadfff4c390ab3"), "message" : "Solicitação de desenvolvimento de front-end", "author" : "Joao" }
{ "_id" : ObjectId("5e582d2ad5cadfff4c390ab2"), "message" : "Solicitação de desenvolvimento de aplicativo mobile", "author" : "joão" }

Aqui podemos perceber 2 coisas:

  • Independente do case-sensitivity, conseguimos o resultado esperado
  • Independente dos diacritics (acidentes nas palavras, ex: acentos), também recuperamos o esperado

Isso porque, a partir do mongo 3.2, foi introduzida uma nova versão do textIndex, a versão 3. Esta nova versão é por padrão case-insensitive e diacritics-insensitive. Isso significa, por exemplo, que não há distinção entre:

  • A, B e C de a, bc
  • á, É, ó e Ú de a, E, o e U

Colocando o Stemming em prática

Vamos agora realizar uma busca pela palavra pedra:

> db.jobs.find({ $text: { $search: 'pedra' } })

Vemos que, por conta do stemming, conseguimos o resultado com o autor Pedro:

{ "_id" : ObjectId("5e582d2ad5cadfff4c390ab4"), "message" : "Solicitação de desenvolvimento de back-end", "author" : "Pedro" }

Stemming não é fácil de entender. Parece previsível, mas não é. Isso é inclusive agravado pelo fato de que apenas matches completos são entendidos como resultados em uma full-text search. Sem aprofundar muito no assunto, vou explicar de forma prática o porque esse caso funcionou, e vou mostrar um outro exemplo onde não teríamos sucesso.

Tudo está ligado a linguagem de processamento de texto chamada Snowball, utilizada pelo MongoDB por baixo dos panos. Ela é a responsável pelo stemming.

Podemos brincar um pouco com ela através deste link, que permite visualizar qual stem a lib está abstraindo a partir da palavra inserida.

Caso 1

Nossa caso da busca com a palavra pedra. Ao visualizarmos através do link acima, vemos que para a palavra pedra, o stem obtido é pedr.

Sendo assim, é possível obter pedro a partir deste stem, e é por isso que obtivemos o resultado esperado.

Caso 2

Se tivéssemos um documento com o autor com o nome de Paullo, e fizéssemos uma busca por Paulo, não obteríamos o resultado esperado. Isso porque, analisando o stem retornado pela Snowball, não seria possível obter o resultado. Explico:

  • O resultado do stemming de Paulo é Paul
  • O resultado do stemming de Paullo é Paull

Aqui entra o ponto que comentei sobre buscas com matches exatos. Paul não é igual a Paullo e também não é igual a Paull. Mesmo que esteja contido em ambos os casos, não é um match completo. Então, neste caso, nenhum resultado é devolvido.

Um outro ponto interessante sobre stemming e stop-words: Se quiser que seu banco deixe de fazer ambos, para devolver apenas matches exatos de palavras, ignorando stems e também poder buscar por stop-words, basta passar o idioma do índice como none:

> db.jobs.createIndex({ author: 'text' }, { default_language: ‘none’ })

Indexando mais de um campo como texto

Citando diretamente a documentação do MongoDB:

“A collection can have at most one text index.”

Ok…então, como fazemos? Criamos um índice composto. Vamos destruir nosso índice atual e criar o novo:

> db.jobs.dropIndex('author_text')
> db.jobs.createIndex({ message: 'text', author: 'text' }, { default_language: 'pt' })

Podemos fazer uma busca bem interessante agora:

> db.jobs.find({ $text: { $search: 'solicitando' } })

Aqui, os stems ajudaram a encontrar o resultado. O stem para solicitando é solicit, que é o mesmo stem para solicitação. Sendo assim, ocorreu um match e obtivemos o resultado.

Uma dica que ajuda a analisarmos de que forma a busca está sendo feita, e o que está sendo utilizado como base de busca, é utilizar o método explain do MongoDB:

> db.jobs.find({ $text: { $search: 'solicitando desenvolvimento' } }).explain(true)

O resultado é bem comprido, pois mostra tudo o que foi analisado e todas as etapas executadas até chegar no resultado. Para a nossa análise do índice, precisamos olhar para a chave no caminho winningPlan.parsedTextQuery:

"parsedTextQuery" : {
  "terms" : [
    "desenvolv",
    "solicit"
  ],
  "negatedTerms" : [ ],
  "phrases" : [ ],
  "negatedPhrases" : [ ]
}

Aqui conseguimos ver que foram utilizados 2 stems nesta busca, no campo terms: desenvolv e solicit.

Aproveito para explicar um pouco o que são cada um dos itens inclusos na parsedQuery:

  • terms: Os termos buscados, de fato. Podem ser stems, ou a palavra toda, caso não hajam stems.
  • phrases: Termos buscados explicitamente de forma exata. É quando queremos encontrar exatamente aquele valor nos documentos. É informado entre aspas duplas.
  • negatedTerms e negatedPhrases: Fazem o mesmo que os terms e phrases, mas de forma exclusiva. Isso também é proporcionado pelo Snowball.

Para exemplificar, podemos fazer a seguinte query, que abrange todos os casos acima:

> db.jobs.find({ $text: { $search: 'solicitando -"back-end"' } })

Obteremos o seguinte resultado:

{ "_id" : ObjectId("5e582d2ad5cadfff4c390ab3"), "message" : "Solicitação de desenvolvimento de front-end", "author" : "Joao" }
{ "_id" : ObjectId("5e582d2ad5cadfff4c390ab2"), "message" : "Solicitação de desenvolvimento de aplicativo mobile", "author" : "João" }

Ou seja, os mesmos resultados de antes, menos o resultado contendo “back-end”.

Peso

Podemos definir pesos diferentes para os campos indexados em um índice textual composto. Isso pode ser de grande para definir o campo com maior relevância para a busca. Vamos criar uma nova collection com um novo índice para exemplificar:

> db.posts.insert([
  { title: 'Javascript no back-end', description: 'Estruturando uma API com NodeJS' },
  { title: 'NodeJS + Express - Back-end ágil' , description: 'Como construir uma API rapidamente'  }
])
> db.posts.createIndex({ title: 'text', description: 'text' }, { default_language: 'pt' })

Se buscarmos por NodeJS, veremos que, mesmo com uma pequena diferença, o resultado com o maior score foi o que possui NodeJS na sua descrição, e não no título.

> db.posts.find({ $text: { $search: 'nodejs' } }, { score: { $meta: 'textScore'} })
{ "_id" : ObjectId("5e584028d5cadfff4c390abc"), "title" : "Javascript no back-end", "description" : "Estruturando uma API com NodeJS", "score" : 0.6666666666666666 }
{ "_id" : ObjectId("5e584028d5cadfff4c390abd"), "title" : "NodeJS + Express - Back-end ágil", "description" : "Como construir uma API rapidamente com Javascript", "score" : 0.6 }

Não é o que queremos. Temos preferência por matches no título.

Vamos então destruir nosso índice e criar um novo contendo os pesos adequados para cada campo:

> db.posts.dropIndex('title_text_description_text')
> db.posts.createIndex({ title: 'text', description: 'text' }, { default_language: 'pt', weights: { title: 5, description: 1 } })

Aqui estamos especificando que o peso do campo title deve ter uma correspondência 5x maior que no campo description: 5 para 1.

Se realizarmos a mesma busca agora, veremos a diferença nos scores:

> db.posts.find({ $text: { $search: 'nodejs' } }, { score: { $meta: 'textScore'} })
{ "_id" : ObjectId("5e584028d5cadfff4c390abc"), "title" : "Javascript no back-end", "description" : "Estruturando uma API com NodeJS", "score" : 0.6666666666666666 }
{ "_id" : ObjectId("5e584028d5cadfff4c390abd"), "title" : "NodeJS + Express - Back-end ágil", "description" : "Como construir uma API rapidamente com Javascript", "score" : 3 }

Sorting

Já vimos que todo resultado de uma full-text search possui um score. Isso é, possui uma pontuação, e quanto mais alta esta pontuação, o algoritmo entende que mais ideal é aquele resultado.

Conforme vimos no resultado da busca acima, apesar da maior correspondência no segundo resultado da lista (score 3), ele veio exatamente desta forma: o segundo da lista.

Isso acontece porque os resultados de uma full-text search não são ordenados pelo score, e sim pela ordem com que são encontrados pelo MongoDB.

Para realizar o sort por score, podemos alterar nossa busca da seguinte forma:

> db.posts.find({ $text: { $search: 'nodejs' } }, { score: { $meta: 'textScore'} }).sort({ score: { $meta: 'textScore' } })
{ "_id" : ObjectId("5e584028d5cadfff4c390abd"), "title" : "NodeJS + Express - Back-end ágil", "description" : "Como construir uma API rapidamente com Javascript", "score" : 3 }
{ "_id" : ObjectId("5e584028d5cadfff4c390abc"), "title" : "Javascript no back-end", "description" : "Estruturando uma API com NodeJS", "score" : 0.6666666666666666 }

Finalizando

Como vimos, existem várias peculiaridades em utilizar full-text search com MongoDB. É uma excelente ferramenta, mas precisa ser cuidadosamente utilizada, após uma análise profunda do caso de uso onde será empregada. 

Questões como stemming podem atrapalhar uma implementação de uma busca global em uma coleção, quando o cenário padrão é o usuário buscar por meias-palavras, por exemplo.

Assim como utilizar a busca textual sem stemming e stop-words através da opção { default_language: none } pode ser útil para alguns casos, mas vai acarretar em um impacto de performance bem mais severo, já que buscas por a, de, por, para começarão a retornar resultados.