ASP.NET MVC + Lucene.Net ( Parte 1 ) – Criando o projeto e o motor de busca

Standard

Com certeza este não é um tema que se encontra facilmente por aí, principalmente em português. Realizei algumas pesquisas na internet e simplesmente não achei NADA sobre o Lucene.Net em português. Em inglês existem alguns artigos legais e bem explicativos sobre o assunto.
Porém, gostaria de falar não apenas sobre a criação de um motor de busca, mas também sobre um recurso muito interessante chamado SpellChecker (Corretor Ortográfico).
Eu estarei dividindo este artigo em duas partes, sendo a primeira destinada a criação do projeto e implementação do motor de busca, e a segunda com a junção do corretor ortográfico, dicionário de sinônimos, e tratamento de preposições e artigos.

Let’s go!

Sobre o Lucene.Net

Basicamente o Lucene.Net é uma biblioteca gratuita criada para fornecer uma estrutura para a implementação e optimização de motores de buscas potentes.
O Lucene.Net contém algumas APIs internas que permitem a criação de índices de texto, implementação de pesquisa avançada, consideração de sinônimos, definição do tipo de comparação nas buscas (OR/AND), podendo até mesmo ser facilmente integrada com corretor ortográfico (SpellChecker).

Para se ter uma noção da potência e utilização do Lucene, os sites wikipedia.org, monster.com e linha1.com.br a utilizam.

Até o momento existem diversas versões do Lucene.Net e seu código-fonte está no GitHub. Veja abaixo os links para maiores informações e download da biblioteca com a versão utilizada no artigo:

Lucene.Net
http://incubator.apache.org/lucene.net/

GitHub
https://github.com/apache/lucene.net

Download da biblioteca com a versão do artigo (2.4.0.2)

Mas afinal, o que iremos criar?

Iremos criar um motor de busca para consultórios e clínicas médicas, onde será disponibilizada uma busca livre (como a do Google por exemplo) e será apresentado os resultados encontrados. Em seguida implementaremos um mecanismo de correção ortográfica, onde será sugerida uma palavra em caso de não haver resultados encontrados, levando em consideração uma coleção de sinônimos.

Criando o Projeto

Primeiramente iremos criar duas tabelas, uma de Consultórios e outra de Especialidades. Em seguida será inserido alguns registros, como nas imagens abaixo:

Imagem 1

Imagem 2

Em seguida criaremos um novo projeto em MVC, e dentro do diretório Model, vamos criar um arquivo do tipo LINQ to SQL e arrastar nossas tabelas criadas anteriormente:

Imagem 3

Até aqui nada de novo, apenas criamos as tabelas de consultórios e especialidades, onde serão disponibilizados na busca.

Dando continuidade, vamos adicionar agora a referência do Lucene.Net ao nosso projeto, como abaixo:

Imagem 4

Agora vamos criar um controller chamado BuscaController, definiremos também a instância de nosso DataContext e uma propriedade chamada caminhoIndice, responsável apenas por manter o caminho de onde ficará o Indice para o motor de busca.

NOTA: O motor de busca do Lucene trabalha em cima de um indice, é nele que ficará todas as informações referentes ao que será possível buscar. Este indice é criado automaticamente pela biblioteca do Lucene, porém, precisamos passar o caminho onde será armazenado o mesmo.

Entendendo e criando a lógica para o Índice

A primeira coisa que devemos entender é a necessidade do índice ser recriado a cada busca realizada. Isso mesmo, iremos realizar a busca com os consultórios existentes em nossa base de dados, porém, após a primeira criação do índice com as informação da base, devemos recria-lo com os dados atuais a cada solicitação de busca. Justamente para que a busca seja realizada com as informações atuais existentes no banco de dados. Veremos como criar o indice mais
para a frente no artigo.

Code Code Code

Agora vamos definir uma classe de auxílio, para tanto vamos criar um diretório chamado Helpers na raiz de nosso projeto, e em seguida adicionar uma classe chamada Suporte com o seguinte código:

public static class Suporte
{
    public static string RemoveAcentos(string texto)
    {
        if (texto == null) return string.Empty;

        string comAcentos = "ÄÅÁÂÀÃäáâàãÉÊËÈéêëèÍÎÏÌíîïìÖÓÔÒÕöóôòõÜÚÛüúûùÇç";
        string semAcentos = "AAAAAAaaaaaEEEEeeeeIIIIiiiiOOOOOoooooUUUuuuuCc";

        for (int i = 0; i < comAcentos.Length; i++)
            texto = texto.Replace(comAcentos[i].ToString(), semAcentos[i].ToString());

        return texto;
    }
}

NOTA: Isso é necessário para que nosso motor de busca ignore todos os tipos de acentos.

Ok, vamos agora em nosso controller BuscaController vamos inicialmente adicionar 4 métodos:

  • IndiceCriar (Cria o índice dentro do diretório definido na propriedade caminhoIndice)
  • IndiceExiste (verifica se o índice existe dentro do diretório definido na propriedade caminhoIndice)
  • IndiceAtualizaTodos (Realiza a atualização do índice para todos os consultórios)
  • IndiceAtualiza (Realiza a atualização do índice apenas um consultório)

Vejamos abaixo como fica o código-fonte de nosso controller até o momento:

using Lucene.Net.Documents;
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Index;

namespace LuceneComMVC.Controllers
{
    public class BuscaController : Controller
    {
        private readonly DBDataContext _db = new DBDataContext();
        private string caminhoIndice = @"C:LuceneIndex";

        /// <summary>
        /// Método utilizado para a criação do índice no diretório definido
        /// </summary>
        private void IndiceCriar()
        {
            //Cria o índice
            IndexWriter writer = new IndexWriter(this.caminhoIndice, new StandardAnalyzer(), true, IndexWriter.MaxFieldLength.LIMITED);
            writer.Close();
        }

        /// <summary>
        /// Método que verifica se existe um índice no diretório definido
        /// </summary>
        public bool IndiceExiste()
        {
            //Verifica se existe o índice
            return IndexReader.IndexExists(this.caminhoIndice);
        }

        /// <summary>
        /// Método que atualiza o índice para todos os consultórios
        /// </summary>
        private void IndiceAtualizaTodos()
        {
            foreach (Consultorio c in _db.Consultorios)
                this.IndiceAtualiza(c);
        }

        /// <summary>
        /// Método que atualiza o índice para cada consultórios
        /// </summary>
        public void IndiceAtualiza(Consultorio c)
        {
            //Caso o índice não exista, cria-se um novo
            if (!IndiceExiste())
                IndiceCriar();

            //Cria um objeto para a modificação do índice (IndexModifier) e deleta o item do índice referênte ao código do consultório
            IndexModifier modifier = new IndexModifier(this.caminhoIndice, new StandardAnalyzer(), false);
            modifier.DeleteDocuments(new Term("cod", c.codConsultorio.ToString()));

            //Fecha o modificador do índice
            modifier.Close(); 

            //Cria um objeto para a inserção de campos no índice
            IndexWriter writer = new IndexWriter(this.caminhoIndice, new StandardAnalyzer(), false, IndexWriter.MaxFieldLength.LIMITED);

            //Cria um novo Documento da bibliotéca do Lucene
            Document doc = new Document();

            //Adiciona dois novos campos (Field) no Documento criando anteriormente
            doc.Add(new Field("cod", c.codConsultorio.ToString(), Field.Store.YES, Field.Index.NOT_ANALYZED));
            doc.Add(new Field("especialidade", Suporte.RemoveAcentos(c.Especialidade.nome), Field.Store.NO, Field.Index.ANALYZED));

            //Adiciona o Documento no objeto de inserção do índice
            writer.AddDocument(doc);

            writer.Optimize();
            writer.Close();
        }

    }
}

Irei explicar melhor a definição do método IndiceAtualiza. Como podemos observar, a primeira condição é para a verificação da existencia do índice, em seguida é criado um modificador para o índice e deletado todos os itens que possuam o código do consultório como termo.

Após, é criado um objeto IndexWriter para a inserção dos campos em nosso índice. Como podemos observar, o mesmo recebe 4 parâmetros:

  • 1º parâmetro: Caminho onde está localizado o índice
  • 2º parâmetro: Definição de um novo analizador para a inserção no índice
  • 3º parâmetro: True ou False se é para realizar a criação do índice
  • 4ª parâmetro: Definição do tamanho máximo do campo, podendo ser LIMITED ou UNLIMITED

Logo em seguida criamos um novo Document, localizado no namespace Lucene.Net.Documents.

Agora vamos criar efetivamente os campos que nosso índice possuirá, observe que adicionamos dentro do objeto doc um novo Field para cada propriedade do consultório, no caso o código e especialidade.

Como parâmetros explicarei cada um:

  • 1º parâmetro: Nome do campo
  • 2º parâmetro: valor do campo
  • 3º parâmetro: Definição do Field.Store, ou seja, se o valor será armazenado no índice. Podendo ser NO ou YES
  • 4º parâmetro: Definição do Field.Index, ou seja, se será possível a busca pelo campo e se o mesmo será analizado

E por fim, o documento é adicionado no objeto IndexWriter.

Criando o método que realiza a busca

Primeiramente vamos definir os seguintes usings:

using Lucene.Net.Store;
using Lucene.Net.Search;
using Lucene.Net.QueryParsers;

Agora sim vamos criar o método que irá realizar efetivamente nossa busca:

private List<Consultorio> RealizaBusca(string txt)
{
    //Chama método que atualiza o índice de todos os consultórios
    this.IndiceAtualizaTodos();

    //Obtém o diretório do índice
    Directory directorio = FSDirectory.GetDirectory(this.caminhoIndice, false);

    //Cria um objeto para leitura (apenas leitura)
    IndexReader indexReader = IndexReader.Open(directorio, true);

    //Cria um objeto para a busca
    Searcher indexSearch = new IndexSearcher(indexReader);

    //Define o campo que será buscado no índice
    QueryParser oParser = new QueryParser("especialidade", new StandardAnalyzer());

    //Define que o operador AND será aplicado a query da nossa busca
    oParser.SetDefaultOperator(QueryParser.Operator.AND);

    //Cria a query que será efetuada a busca
    var query = oParser.Parse(Suporte.RemoveAcentos(txt));

    //Efetua a busca e obtém os resultados encontrados
    TopDocs resultados = indexSearch.Search(query, indexReader.MaxDoc());

    List<Consultorio> listaResultados = new List<Consultorio>();

    //Para cada resultado encontrado
    foreach (var hit in resultados.scoreDocs)
    {
        //Seleciona o documento
        var documentoDaBusca = indexSearch.Doc(hit.doc);

        //Seleciona o valor do campo "cod"
        var codConsultorio = documentoDaBusca.Get("cod");

        //Seleciona o Consultório com o respectivo código
        Consultorio c = _db.Consultorios.Where(co => co.codConsultorio == Convert.ToInt32(codConsultorio)).FirstOrDefault();

        if (c != null)
            listaResultados.Add(c);
    }

    //Fecha os manipuladores do índice e o diretório aberto
    indexSearch.Close();
    indexReader.Close();
    directorio.Close();

    //Retorna lista com os consultórios encontrados
    return listaResultados;
}

Por hora é isso, agora vamos criar um Model para representar nossa View, criarei um Model chamado Busca:

public class Busca
{
    public Busca() { this.resultadoBusca = new List<Consultorio>(); }

    public Busca(List<Consultorio> resultados, string texto)
    {
        this.resultadoBusca = resultados;
        this.textoBuscado = texto;
    }

    public List<Consultorio> resultadoBusca { get; set; }
    public string textoBuscado { get; set; }
}

E vamos criar também nossa ActionResult:

public ActionResult Busca(string txt)
{
    if (!String.IsNullOrWhiteSpace(txt))
    {
        List<Consultorio> resultados = this.RealizaBusca(txt);

        return View(new Busca(resultados, txt));
    }
    else
        return View(new Busca());
}

Por fim, vamos criar nossa View:

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<LuceneComMVC.Models.Busca>" %>

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
	Busca de Consultórios
</asp:Content>

<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">

    <script type="text/javascript">
        $(document).ready(function () {
            $("#btnBuscar").click(function () {
                window.location = "/Busca/Busca/?txt=" + $(this).attr("id");
            });
        });
    </script>

    <div>
        <center>
            <%= Html.TextBox("campoBusca", Model.textoBuscado, new { @style = "width:500px;" }) %>
            <br />
            <input type="button" id="btnBuscar" value="Buscar" />
        </center>
    </div>

    <%--Chama UserControl para renderizar os consultórios--%>
    <% Html.RenderPartial("RenderizaResultadosBusca", Model.resultadoBusca); %>

</asp:Content>

Observe que nossa View é tipada com o Modelo Busca criado anteriormente. Atente-se também que é chamado um UserControl com o nome de RenderizaResultadosBusca, este UserControl é responsável pela renderização do resultado de nossa busca. Vamos cria-lo:

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<List<LuceneComMVC.Models.Consultorio>>"  %>

<br />
<br />
<table>
<% foreach(LuceneComMVC.Models.Consultorio c in Model)
   {  %>
    <tr>
        <td><%= c.codConsultorio  %></td>
        <td><%= c.Especialidade.nome %></td>
        <td><%= c.nome  %></td>
        <td><%= c.logradouro %></td>
        <td><%= c.cidade  %></td>
        <td><%= c.uf  %></td>
    </tr>
 <%} %>
 </table>

Bom, desta forma nossa busca já funciona. Vamos efetuar uma busca pela especialidade “ortopedista”, observe que é apresentado os 3 resultados existentes no banco de dados:

Imagem 5

NOTA: Observe que foi criado o menu Busca em nossa Master Page.

Porém, nossa busca pode ser melhorada (e muito) :). Veja, se efetuarmos uma busca como “Ortopedista em São Paulo”, não será apresentado nenhum resultado! Isso por que a coluna “cidade” na tabela de consultórios não foi indexada em nosso índice, apenas a coluna referênte ao código e especialidade.
Outro ponto importante é que, o motor de busca do lucene realiza sua busca considerando TODAS as palavras digitadas na busca. Nesse caso mesmo indexando a coluna “cidade” os resultados não seriam apresentados, pois ainda existe a palavra “em”. Solucionaremos este problema criando uma rotina para remover preposições e artigos de nosso texto a ser buscado.
Além dessas implementações, criaremos também um corretor ortográfico, o mesmo será muito parecido com o do Google, ou seja, ao efetuar uma busca e não houver resultados, será apresentado uma sugestão a ser buscada levando em consideração um dicionário de sinônimos.

Puxa, quanta coisa. Mas tudo isso e algumas outras dicas é assunto para o próximo e último artigo da série ASP.NET MVC + Lucene.Net.

Até lá!!!

 

Faça o download do projeto aqui.

 

6 thoughts on “ASP.NET MVC + Lucene.Net ( Parte 1 ) – Criando o projeto e o motor de busca

Leave a Reply

Your email address will not be published. Required fields are marked *