Solr - Lucene

miércoles, 2 de febrero de 2011

Ejemplo de Solr para sugerir términos en las búsquedas.


El siguiente texto no pretende ser una guía detallada de cómo hacer las cosas, sino una suerte de referencia en los puntos tratados; una humilde devolución a la comunidad, en este mi primer post, que espero a alguna persona le sea de utilidad.


Pre-requisitos:
Para seguir el ejemplo que se planteará es necesario tener instalado Apache Solr. Solr es una plataforma de búsqueda del proyecto open source Apache Lucene, esta desarrollada en Java y funciona dentro de de un contenedor Servlet, como puede ser Jetty que viene embebido dentro del ejemplo de Solr y el cual utilizaré, o Tomcat si se prefiere. También es necesario tener instalado MySQL y Solrj, que es un cliente java que usaré para interactuar con Solr; también viene dentro de él.
La idea de este ejemplo es más didáctica y de estadística que de funcionalidad.


Lo que pretendo, es generar una herramienta que sugiera términos de búsqueda, basados en las búsquedas realizadas por otros usuarios en el pasado. Para ello montare dos núcleos diferentes dentro de Solr.
El primero tendrá guardados muchos
Tweets de Twitter y será con el que interactuará el usuario final; utilizaré un template el cual se puede configurar de diversas maneras, según las necesidades de cada cliente; parte se explicará a medida que se avance en el ejemplo.
El segundo núcleo contendrá las frases buscadas por los usuarios en el primer núcleo, después de ser analizadas y filtradas, y con estas frases les irá sugiriendo a los usuarios las palabras a escribir, a medida que escriben en una caja de texto; se profundizará sobre este tema en breve.
Para poder realizar lo antes enunciado se necesitará configurar distintos archivos de Solr, se utilizará MySQL y también una aplicación hecha en Java; el siguiente
vinculo muestra los lineamientos sobre los que me basé para realizar el presente ejemplo.


Manos a la obra!
Primero, si tengo Solr iniciado lo detengo.
Son necesarias dos carpetas para guardar todo lo referente a los núcleos; para este ejemplo core1 y core2. Primero creo la carpeta core1 dentro de la raíz del Solr (“Solr Home”), dentro de esta carpeta muevo, no copio, la carpeta “conf”. Ahora copio el core1, también en la raíz de Solr, y le cambio el nombre a core2. La siguiente imagen muestra el esquema de archivos:

  


Por último debo crear el archivo solr.xml, que es el encargado de decirle a Solr cuantos núcleos tiene corriendo y su ubicación, entre otras cosas; mirar su wiki mayor referencia.
Este sería el código a agregar dentro de solr.xml:
<?xml version="1.0" encoding="UTF-8" ?>
<solr persistent="true" sharedLib="lib">
       <cores adminPath="/admin/cores">
               <core name="core1" instanceDir="core1">
                       <property name="solr.data.dir" value="solr/core1/data"/>
               </core>
               <core name="core2" instanceDir="core2">
                       <property name="solr.data.dir" value="solr/core2/data"/>
               </core>
       </cores>
</solr>

Para probar el funcionamiento enciendo Solr, si no cambiamos la configuración por defecto, ingreso a la siguiente dirección y me tendría que responder solr.

El primer núcleo (core1), como ya mencione anteriormente, contiene el índice principal con todos los Tweets completos utilizados para este ejemplo y se utilizará para responder todas las consultas realizadas por el usuario. Para que los usuarios puedan interactuar con Solr se implemento una interfaz gráfica con Velocity, la cual me permite organizar las búsquedas de distintas maneras utilizando el VelocityResponseWriter; no es el objetivo de este texto mostrar la configuración del mismo.
El segundo (core2), también ya mencionado anteriormente, contendrá un índice con todas las frases que los usuarios buscan en el core1. El mismo me permitirá sugerirle al usuario, a medida que ingresa texto en la caja de búsqueda, las posibles frases que desea escribir; por dar un ejemplo, si quisiese escribir “argentina” y voy escribiendo “ar”, me podría sugerir “arco”, “argumento”, “arrendar”, etc., si estas palabras estuvieran guardadas dentro del índice.
Para poder ingresar (indexar) en el core2 las frases buscadas por el usuario, en el core1, es necesario analizar las búsquedas realizadas. La manera de hacerlo en este ejemplo, es habilitando el log que genera Jetty.

Del log obtendré las frases buscadas y por medio de la biblioteca Solrj le preguntaré a Solr, si las mismas se corresponden con los datos indexados dentro del índice del core1.
Por ejemplo, si en el índice del core1 tuviera almacenados documentos relativos a la fauna, y el usuario buscase la palabra “motherboard”, no tendría sentido indexar esta palabra en el core2; recuerden esto es un ejemplo, que podría realizarse de otras maneras, pero la idea es cubrir varios tópicos distintos en un mismo ejemplo.
Para habilitar el log de Jetty, apago Solr si esta encendido, y modifico el archivo jetty.xml que se encuentra dentro de la carpeta /etc. Si observo dentro del archivo aprecio que en una parte del xml está deshabilitado el código para que guarde el log; lo habito y reinicio el servidor para que tome los cambios; los archivos de logs serán guardados por su fecha en /logs.

Parte modificada del xml:
<Ref id="RequestLog">
    <Set name="requestLog">
        <New id="RequestLogImpl" class="org.mortbay.jetty.NCSARequestLog">
            <Set name="filename">
               <SystemProperty name="jetty.logs" default="./logs"/>
                  /yyyy_mm_dd.request.log
            </Set>
           <Set name="filenameDateFormat">yyyy_MM_dd</Set>
           <Set name="retainDays">90</Set>
           <Set name="append">true</Set>
           <Set name="extended">true</Set>
           <Set name="logCookies">true</Set>
           <Set name="LogTimeZone">GMT</Set>
       </New>
    </Set>
</Ref>


Una vez obtenidas las frases y si contienen o no resultados, analizaré la frecuencia de búsqueda de las mismas. La cantidad de repeticiones de cada frase en log me indicará la ponderación de las mismas.
Por ejemplo, si en el log figura “casa” cuatro veces y “casamiento” tres veces, al ir escribiendo “cas”, por la ponderación otorgada, se sugerirá primero “casa” y luego “casamiento”.
Una vez concluida la selección de frases, su frecuencia y si tienen resultados, o no, debo guardar toda esta información en una base de datos; para ello utilicé MySQL.
Esquema para la base de datos:

  


Modificar schema.xml (core2) para que coincidan los campos del autosuggest con los del índice.
<!--campos para usar autosuggest-->
<field name="user_query" type="edgytext" indexed="true" stored="true" omitNorms="true" omitTermFreqAndPositions="true" />
<field name="count" type="int" indexed="true" stored="false" omitNorms="true" omitTermFreqAndPositions="true" />
 
Habilitar “Data Import Handler” para poder comunicar a Solr con MySQL; al hacerlo, se explica a continuación cómo, le digo a Solr en que campos de su índice deberá guardar los valores provenientes de MySQL y de que campos; es un emparejamiento de datos entre Solr y MySQL.
Para lograr lo antes enunciado modifico solrconfig.xml para que tenga el siguiente código:
<requestHandler name="/indexer/autosuggest" class="org.apache.solr.handler.dataimport.DataImportHandler">
<lst name="defaults">
               <str name="config">dih-config.xml</str>
       </lst>
       <lst name="invariants">
               <str name="optimize">false</str>
       </lst>
</requestHandler>


A continuación creo el archivo dih-config.xml a la misma altura de path que solrconfig.xml (/core2/conf). 
 Agrego el siguiente código:
<?xml version="1.0"?>
<dataConfig>
        <!-- Datos para entablar comunicación con el driver de MySQL,
        y por ende la base de datos -->
        <dataSource type="JdbcDataSource" readOnly="true"
        driver="com.mysql.jdbc.Driver" 
        url="jdbc:mysql://localhost:3306/solr" user="root" password=""/>
<document name="autoSuggester">
<!-- realiza una consulta contra MySQL; trae como resultado “id”,
“query” y “contador” pero sólo de los valores en los cuales el
resultado sea igual a 1; esto trae todas las búsquedas que realizo
el usuario en el core1 y que tuvieron al menos un valor por
respuesta. -->
<entity name="main" query="select id, query, contador from
autosuggest where resultado = 1">
    <!-- “field column” es el campo de MySQL y “user_query”
     es el campo del índice de Solr donde se guardaran los
     valores obtenidos; acá se produce el emparejamiento. -->
    <field column="query" name="user_query"/>
    <field column="contador" name="count"/>
                            <field column="id" name="id"/>
                         </entity>
</document>
</dataConfig>


Parte del código java (agradecimiento a  Juan “Grande”) que toma los frases consultadas, y su frecuencia, del log de Jetty, analiza si los mismos tienen coincidencias a través de Solrj, los guarda en MySQL y luego los indexa, utilizando “Data Import Handler”; obtiene los datos de MySQL y los guarda en el core2.
El Parámetro necesario para llamar la aplicación es la dirección y nombre del archivo del log.


SolrLogImporter.java
package com.plugtree.solr;
public class SolrLogImporter {
      
private static Logger log = LoggerFactory.getLogger(SolrLogImporter.class);

private Collection<LogQuery> queries = new LinkedList<LogQuery>();
      
       private SolrServer solrServer;
       public SolrLogImporter(String url) throws MalformedURLException {
           solrServer = new CommonsHttpSolrServer(url);
       }
      
       private void loadLog(String filename, String handler) throws IOException, SolrServerException
       {
           BufferedReader in = new BufferedReader(new FileReader(filename));
           String s;
           Pattern p = Pattern.compile(".*GET " + handler + "\\?q=([^&]*).*");
           s = in.readLine();
           while(s != null) {
               Matcher m = p.matcher(s);
               if (m.matches()) {
                   String query = m.group(1);
                   query = URLDecoder.decode(query, "UTF-8");
                   queries.add(new LogQuery(query, hasResults(query)));
               }
               s = in.readLine();
           }
           in.close();
       }
      
       private boolean hasResults(String q) throws SolrServerException {
           SolrQuery solrQuery = new SolrQuery();
           solrQuery.setQuery(q);
           solrQuery.setQueryType("dismax");
      
           QueryResponse solrResponse = solrServer.query(solrQuery);
           SolrDocumentList results = solrResponse.getResults();
      
           return results.size()>0;
      }
  
      private void updateDatabase() throws SQLException, ClassNotFoundException {
          Class.forName("com.mysql.jdbc.Driver");
          Connection con = DriverManager.getConnection ("jdbc:mysql://localhost/solr","root", "");

PreparedStatement querySt = con.prepareStatement(
"SELECT contador, id FROM autosuggest WHERE query = ?");

PreparedStatement updateSt = con.prepareStatement(
"UPDATE autosuggest SET contador = ?, resultado = ? WHERE id = ?");
                   
          PreparedStatement insertSt = con.prepareStatement(
          "INSERT INTO autosuggest (id, query, resultado, contador) VALUES (?, ?, ?, ?)");
              
for(LogQuery q: queries) {
    querySt.setString(1, q.getQ());
    ResultSet rs = querySt.executeQuery();
                      
    if(rs.first()) {
        updateSt.setInt(1, rs.getInt(1)+1);
        updateSt.setInt(2, q.hasResults() ? 1 : 0);
        updateSt.setString(3, rs.getString(2));
        updateSt.executeUpdate();
    } else {
         /* TODO Usar ID entero que se autoincremente */
         insertSt.setString(1, UUID.randomUUID().toString());
         insertSt.setString(2, q.getQ());
         insertSt.setInt(3, q.hasResults() ? 1 : 0);
         insertSt.setInt(4, 1);
         insertSt.executeUpdate();
             }
          }
       }
   
       private void updateIndex (String url) throws MalformedURLException, IOException {
           URL dir =  new URL(url);
  InputStream response = dir.openStream();
  BufferedReader reader = new BufferedReader(new InputStreamReader(response));
  reader.close();
       }
      
       public static void main(String[] args) {
           try {
               SolrLogImporter solrLogImporter = 
               new SolrLogImporter("http://localhost:8983/solr/core1/");            
      solrLogImporter.loadLog(args[0], "/solr/core1/browse");
      try {
          solrLogImporter.updateDatabase();
          try {
              solrLogImporter.updateIndex 
              ("http://localhost:8983/solr/core2/indexer/autosuggest?command=full-import");
          catch (MalformedURLException e) {
              e.printStackTrace();
          } catch (IOException e) {
              e.printStackTrace();
          }
      } catch(SQLException ex) {
          log.error("Unable to update database", ex);
      } catch(ClassNotFoundException ex) {
          log.error("Unable to load SQL driver", ex);
  }
} catch(IOException ex) {
   log.error("Unable to read log file", ex);
} catch(SolrServerException ex) {
   log.error("Unable to query Solr server", ex);
}
    }
}

Mira el código completo acá.


Para que funcione el autosuggest es necesario modificar parte del template de velocity, para que coincidan los nombres de las variables de nuestros datos (índice), que llame a la dirección correcta (core1, core2) y que sepa que campos debe devolver para realizar el autosuggest; los archivos se encuentran dentro de /core1/conf/velocity.
Doc.vm -> modificar nombre de los campos de nuestro índice.
head.vm -> esto debe apuntar al core2, que realiza el auto-suggest, pero el archivo es del core1 quien lo llama; utiliza JQuery para realizar su trabajo (para mayor referencia dirigirse a su página principal). 

La lógica del código debería ser similar a lo siguiente:
<script>
    $(document).ready(function(){
        extraParams = {
            'q': function() { return 'user_query:' + $("\#q").val();},
            'sort':'count desc, user_query asc',
            'wt': 'velocity',
            'v.template': 'campo'
        };
              
        $("\#q").autocomplete('http://localhost:8983/solr/core2/select', {                      'extraParams':extraParams
        });
    });
</script>
campo.vm -> va dentro del core2 y es el que devuelve los valores del campo al ser llamado por jquery. Ejemplo del código:

#foreach($doc in $response.results)
       $doc.getFieldValue('user_query')
#end


Agradezco a Diego, Juan, Tomas y Matías por ayudarme en la confección del mismo.

No hay comentarios:

Publicar un comentario