Haystack - Buscando la aguja en el pajar

../images/haystack/mbordese.png
Autor:Matias Bordese
Bio:Computólogo y desarrollador Python/Django, fan del software y las tecnologías libres.
Web:http://matias.bordese.com.ar
Email:mbordese@gmail.com
IRC:matiasb (freenode)
Twitter:@mbordese

Realizar búsquedas sobre el contenido de un sitio es un problema habitual a resolver si uno hace desarrollo web. Sin embargo, para los que nos gusta trabajar con Python y Django es un problema para el cual este framework no provee una solución en sí mismo.

Afortunadamente existe una app que se presenta como una muy buena alternativa para encontrar lo que se busca, aunque sea una aguja en un pajar.

../images/haystack/haystack.png

Conceptos previos

Antes de empezar a hablar del tema que nos convoca, algunas definiciones que es importante conocer:

  • Motor de búsqueda (Search engine) Es el sistema que realmente va a resolver el problema de indexar y buscar la información; es el backend de la app de búsqueda, del cual nos abstrae. Existen diversas opciones: Solr, Xapian, Whoosh.
  • Índice Es el almacenamiento utilizado por el motor de búsqueda. En general, la información guardada es no relacional y no sigue un esquema fijo (es decir, distinto a una base de datos relacional).
  • Documento Es un registro en el índice. Se puede pensar como un diccionario. Suele contener un bloque de texto que sirve como contenido primario de búsqueda y campos con metadata adicional.

Haystack

Haystack[1], una app desarrollada por Daniel Lindsley, provee una solución modular para resolver el problema de la búsqueda en Django. En otras palabras y de manera simplista, es una capa de abstracción que permite integrar Django con un motor de búsqueda (como por ejemplo Solr, Whoosh, etc).

Una pregunta que uno podría hacerse es por qué trabajar en un sistema de búsqueda personalizado y no usar directamente Google. Y la verdad es que podemos encontrar ventajas no despreciables:

  • Control sobre qué y qué no se indexa
  • Mejor calidad de la información en el índice
  • Manejo particular de ciertos datos
  • Posibilidad de búsquedas específicas según el contexto

Reconocidos los beneficios, la próxima cuestión que se plantea es por qué deberíamos usar Haystack y no interactuar directamente con el motor de búsqueda, por ejemplo. Habiendo usado Django previamente, existen motivos varios por los cuales deberíamos considerar esta app como potencial solución a nuestro problema:

  • API familiar para desarrolladores Django, parece Django
  • Pluggable backends (soporta Solr, Whoosh y Xapian)
  • El código es independiente del backend
  • Se integra a las apps que queremos sean buscables sin necesidad de modificarlas
  • Buena documentación
  • Nivel de tests aceptable, no se aceptan nuevos commits sin tests

Quiero usar Haystack!

A continuación, la idea es desarrollar un ejemplo simple que muestre cómo usar Haystack en nuestros sitios Django. Para ello tomamos como base el siguiente modelo:

from django.contrib.auth.models import User
from django.db import models

class Entry(models.Model):
    title = models.CharField(max_length=100)
    created = models.DateTimeField(auto_now_add=True)
    author = models.ForeignKey(User)
    tease = models.TextField(blank=True)
    content = models.TextField()

    def __unicode__(self):
        return self.title

Una de las primeras cuestiones que nos va a interesar es la de poder indexar nuestros datos, hacerlos "buscables".

En Haystack, lo que determina qué información se indexa son los SearchIndex [2]. De alguna forma se asemejan a un modelo de Django, o a un Form, en el sentido de que son field-based, y manipulan y almacenan datos.

En general, vamos a crear un SearchIndex por cada modelo que queremos indexar. Hay que tener en cuenta que, para realizar búsquedas complejas entre múltiples modelos, es conveniente definir los nombres de los campos apropiadamente, preservando dichos nombres para los diferentes modelos cuando corresponda.

Definamos pues nuestro SearchIndex. Para ello, creamos una clase que herede de SearchIndex, definimos los campos en los que queremos guardar datos y registramos esta clase asociándola a nuestro modelo (notar en este punto la similitud con el registro de ModelAdmins):

from haystack import indexes, site
from myapp.models import Entry

class EntrySearchIndex(indexes.SearchIndex):
    text = indexes.CharField(document=True, use_template=True)
    author = indexes.CharField(model_attr=user__username)
    created = indexes.DateTimeField()

    def index_queryset(self):
        return Entry.objects.published()

    def prepare_created(self, obj):
        return obj.pub_date or datetime.datetime.now()

site.register(Entry, EntrySearchIndex)

Este código se suele agregar en un archivo search_indexes.py dentro de la app, aunque no es requerido. Siguiendo la convención en el nombre, permitimos que haystack.autodiscover() lo encuentre y registre automáticamente[3].

Notar también que todo SearchIndex debe tener un único campo con el parámetro document=True, indicando que se trata del campo primario de búsqueda. Éste debe mantener el mismo nombre a través de los distintos modelos, por convención se lo llama text.

A dicho campo además podemos asociarle un template (use_template=True), lo que nos permite definir la información a indexar de manera más cómoda. Creamos entonces, en el directorio de templates del proyecto, el archivo search/indexes/myapp/entry_text.txt:

{{ obj.title }}

{{ obj.author.get_full_name }}

{{ obj.tease }}

{{ obj.content }}

Adicionalmente, agregamos a nuestro SearchIndex un par de campos más, útiles al momento de proveer filtros, o para contar con metadata adicional al presentar los resultados al usuario. Vale destacar que al realizar búsquedas a través de Haystack estaremos trabajando sobre el índice del motor de búsqueda, no sobre la base de datos, e idealmente nos gustaría evitar los hits a la misma.

Haystack dispone de una buena variedad de SearchFields [4] para manejar la mayoría de los distintos tipos de datos. Usualmente bastará con pasar el parámetro model_attr para indicar el campo de nuestro modelo del cual provendrán los datos a indexar. Como argumento se puede pasar tanto un atributo como un callable de nuestro modelo.

A veces puede ser necesario un mayor control de la información a indexar. En ese caso, se puede definir una preparación de los datos, previa al indexado. En el ejemplo de arriba definimos el prepare_created, que preprocesa el campo created. Esto debería resultar familiar a la forma en que Django maneja la validación de campos en un Form (y sus métodos clean). De la misma forma, aquí podemos definir métodos prepare_FIELD(self, object); o un prepare(self,object), más general, permitiendo acceder a los distintos campos en self.prepared_data.

Finalmente, en nuestro SearchIndex hacemos un override del método index_queryset, que devuelve qué objetos indexar cuando se hace una actualización del índice.

Existen varios métodos más que podemos personalizar para adaptar el indexado de los datos a nuestras necesidades. Para más detalles pueden consultar en la documentación de Haystack, que se referencia al final del artículo.

El que busca, encuentra

Ahora que ya indexamos la información, sería bueno saber cómo encontrar la aguja en el pajar.

Para buscar sobre nuestro índice contamos con SearchQuerySet [5]. Esta clase está diseñada para efectuar búsquedas de manera eficiente e iterar sobre los resultados de una forma fácil y consistente. Para aquellos que usan frecuentemente los QuerySet del ORM de Django, la API de SearchQuerySet les resultará muy accesible.

SearchQuerySet provee una API limpia, que nos abstrae del motor de búsqueda que usemos como backend. Y al igual que con Django QuerySet, podemos encadenar los métodos de búsqueda uno después de otro para restringir los resultados.

Además, al implementar una interfaz list-like, podemos realizar operaciones para pedir la cantidad de resultados, acceder a los resultados mediante índices posicionales o incluso aplicar slices.

Dentro de SearchQuerySet distinguimos métodos que devuelven un nuevo SearchQuerySet (encadenables entre sí), y otros que no.

Entre los primeros podemos citar, por ejemplo: all, none, filter (soporta field lookups![6]), exclude, order_by, models. Entre los que no: count, best_match, latest. Cada uno de estos hacen lo que uno esperaría, y mapean casi directamente a sus equivalentes en Django QuerySet, a excepción de un par nuevos como models que permite filtrar por modelos, o best_match que devuelve el primero de los resultados encontrados.

Por otro lado, una de las situaciones más comunes consiste sin duda en buscar sobre nuestro campo primario de búsqueda. Para facilitar esto (al usuario y al backend), hay un campo especial llamado content que podemos usar en cualquiera de los métodos que esperan un nombre de campo (filter, exclude, etc) para indicar que queremos buscar sobre el campo que denotamos con document=True en nuestros SearchIndexes.

Llevando a la práctica lo anterior:

>>> import datetime
>>> from haystack.query import SearchQuerySet

# Buscamos en todos los fields marcados con `document=True`.
>>> results = SearchQuerySet().filter(content='hello')
[<SearchResult: myapp.entry(pk=u'3')>]

>>> sqs = SearchQuerySet().models(Entry)
>>> sqs = sqs.filter(created__lte=datetime.datetime.now())
>>> sqs = sqs.exclude(author=daniel)

# La query realmente se hace cuando listamos los resultados (lazy).
>>> sqs
[<SearchResult: myapp.entry (pk=u'5')>, <SearchResult: myapp.entry(pk=u'3')>,
<SearchResult: myapp.entry (pk=u'2')>]

# Podemos iterar sobre los resultados. No hiteamos la DB.
>>> [result.author for result in sqs]
[johndoe, sally1982, bob_the_third]

# Un hit a la DB por cada resultado.
>>> [result.object.user.first_name for result in sqs]
[John, Sally, Bob]

# Más eficiente, una sola query a la DB.
>>> [result.object.user.first_name for result in sqs.load_all()]
[John, Sally, Bob]

Como podemos observar los resultados devueltos son instancias de SearchResult [7], a través de la cual podemos acceder a la metadata indexada para cada resultado, y de ser necesario a la instancia de modelo correspondiente (atributo object).

Hay más

Haystack también provee views y forms simples[8], que cubren los casos más comunes y completan la implementación de todo lo necesario para integrar búsquedas a nuestro sitio.

Las views incluidas permiten: búsqueda básica por términos, búsqueda con filtros por modelos, búsqueda en cuyos resultados se destacan los términos de la consulta ("highligted search") y búsqueda facetada (filtros anotados con el número de resultados por cada valor posible).

No existe prácticamente acoplamiento entre views y forms, y podemos intercambiarlos libremente o definir los propios a nuestra medida.

Además existen varias características más avanzadas que podemos integrar a nuestro sistema de búsqueda mediante Haystack:

  • Highlighting[9]
  • Faceting[10]
  • Boost[11]
  • More like this[12]

Finalmente, pero no menos importante: Haystack es Python y Django! Es decir que, si out-of-the-box no se ajusta a lo que necesitamos, podemos partir de SearchIndex y SearchQuerySet para obtener lo que queremos.

Para cerrar, algunos sitios que usan Haystack y que muestran algunas de las posibilidades que nos brinda esta app[13]:

../images/haystack/nasa_search.png ../images/haystack/recetas_search.png

Los invito a probar Haystack y sacar sus propias conclusiones.

Help PET: Donate

blog comments powered by Disqus

Último cambio: Thu Sep 22 06:38:27 2011.  -  Esta revista está bajo una licencia Creative Commons.