Xapian w Pythonie

Xapian to jedna z dostępnych w pythonie wyszukiwarek pełnotekstowych. Jak wszystkie ma słabą dokumentację (praktycznie samo API) i nie jest przez to łatwa w użyciu. W odróżnieniu od Lucene nie wymaga GCJ (Java) i dostępna jest w wielu językach, również dla PHP czy Ruby. Obecnie Xapian nie obsługuje prawdopodobieństw polskich wyrazów na bazie ich rdzeni (steming), ale i tak jest w miarę użyteczny. Istnieją również pomocnicze projekty jak Xapwrap czy PyXapian, ale nimi nie będę się na razie zajmował. Jeżeli korzystasz z MySQL to lepiej skorzystać z możliwości pełnotekstowego wyszukiwania, jaką daje ta baza danych.

Instalacja

Pakiet "xapian" i "xapian-bindings" powinny być w repozytoriach większości dystrybucji. Niektóre mogą zamiast "xapian-bindings" mieć "xapian-bindings-python" czy "xapian-python". Sam xapian nie posiada modułu pythona, dopiero "xapian-bindings" go zapewnia. Jeżeli nie możesz znaleźć pakietów dla swojej dystrybucji sprawdź stronę projektu.

Wprowadzenie

Oto indekser bazujący na przykładzie rozprowadzanym z xapian-bindings:
import xapian
import string

MAX_PROB_TERM_LENGTH = 64

def p_alnum(c):
    return (c in string.ascii_letters or c in string.digits)

def p_notalnum(c):
    return not p_alnum(c)

def p_notplusminus(c):
    return c != '+' and c != '-'

def find_p(string, start, predicate):
    while start<len(string) and not predicate(string[start]):
        start += 1
    return start


database = xapian.WritableDatabase('test/', xapian.DB_CREATE_OR_OPEN)
stemmer = xapian.Stem("english")
para = '''this is a testing'''
doc = xapian.Document()
doc.set_data(para)
pos = 0
i = 0
while i < len(para):
	i = find_p(para, i, p_alnum)
	j = find_p(para, i, p_notalnum)
	k = find_p(para, j, p_notplusminus)
	if k == len(para) or not p_alnum(para[k]):
		j = k
	if (j - i) <= MAX_PROB_TERM_LENGTH and j > i:
		term = stemmer.stem_word(string.lower(para[i:j]))
		doc.add_posting(term, pos)
		pos += 1
	i = j
database.add_document(doc)
Zapisz kod w pliku i stwórz podkatalog "test" na bazę wyszukiwarki. Następnie wykonaj skrypt. Zmień indeksowaną frazę na:
para = '''this is a test'''
I ponownie wykonaj skrypt. Teraz wyszukiwarka:
import sys
import xapian

try:
    database = xapian.Database('test/')

    enquire = xapian.Enquire(database)
    stemmer = xapian.Stem("english")
    terms = []
    for term in sys.argv[1:]:
        terms.append(stemmer.stem_word(term.lower()))
    query = xapian.Query(xapian.Query.OP_OR, terms)
    print "Performing query `%s'" % query.get_description()

    enquire.set_query(query)
    matches = enquire.get_mset(0, 10)

    print "%i results found" % matches.get_matches_estimated()
    for match in matches:
        print "ID %i %i%% [%s]" % (match[xapian.MSET_DID], match[xapian.MSET_PERCENT], match[xapian.MSET_DOCUMENT].get_data())

except Exception, e:
    print >> sys.stderr, "Exception: %s" % str(e)
    sys.exit(1)
Zapisz do pliku i wykonaj skrypt podając jako parametr szukaną fraze:
python szukaj.py test
W wynikach pojawią się obie frazy, ta z "testing" będzie miała mniejsze prawdopodobieństwo. Po zindeksowaniu kilku tekstów o podobnej tematyce zdolności wyszukiwania pełnotekstowego będą jeszcze bardziej wyraźne. W powyższych skryptach pojawia się:
stemmer = xapian.Stem("english")
Co określa język tekstu - uwzględnia to indekser umożliwiając "logiczne" pełnotekstowe wyszukiwanie. Wspierane są: none, danish (da), dutch (nl), english (en), finnish (fi), french (fr), german (de), italian (it), norwegian (no), portuguese (pt), russian (ru), spanish (es), swedish (sv).

Xapian i indeksowanie danych z bazy danych

W przypadku indeksowania danych z bazy danych wpis w indeksie musi zawierać numer ID wpisu oraz zazwyczaj dodatkowe dane takie jak tytuł i opis wpisu, które wykorzystamy przy wyświetlaniu wyników. Xapian umożliwia dodawanie pól do indeksu. Oto modyfikacja indeksera:
import xapian
import string

MAX_PROB_TERM_LENGTH = 64

def p_alnum(c):
    return (c in string.ascii_letters or c in string.digits)

def p_notalnum(c):
    return not p_alnum(c)

def p_notplusminus(c):
    return c != '+' and c != '-'

def find_p(string, start, predicate):
    while start<len(string) and not predicate(string[start]):
        start += 1
    return start

NEWS_ID = 0
NEWS_TITLE = 1
NEWS_DESC = 2
database = xapian.WritableDatabase('test/', xapian.DB_CREATE_OR_OPEN)
stemmer = xapian.Stem("english")
para = '''this is a testing'''
doc = xapian.Document()
doc.set_data(para)
pos = 0
i = 0
while i < len(para):
	i = find_p(para, i, p_alnum)
	j = find_p(para, i, p_notalnum)
	k = find_p(para, j, p_notplusminus)
	if k == len(para) or not p_alnum(para[k]):
		j = k
	if (j - i) <= MAX_PROB_TERM_LENGTH and j > i:
		term = stemmer.stem_word(string.lower(para[i:j]))
		doc.add_posting(term, pos)
		pos += 1
	i = j
	doc.add_value(NEWS_ID, str(323))
	doc.add_value(NEWS_TITLE, 'Tytul newsa')
	doc.add_value(NEWS_DESC, 'bla bla bla')
database.add_document(doc)
Pojawiły się trzy wiersze z doc.add_value(NEWS_ID, str(323)). Pierwszy parametr to liczba a drugi to wartość. My zastosowaliśmy trzy - na tytuł, opis i numer ID. Usuń zawartość katalogu "test" i wykonaj skrypt, a następnie wyszukaj wpis korzystając ze zmodyfikowanej wyszukiwarki:
import sys
import xapian

NEWS_ID = 0
NEWS_TITLE = 1
NEWS_DESC = 2
try:
    database = xapian.Database('test/')

    enquire = xapian.Enquire(database)
    stemmer = xapian.Stem("english")
    terms = []
    for term in sys.argv[1:]:
        terms.append(stemmer.stem_word(term.lower()))
    query = xapian.Query(xapian.Query.OP_OR, terms)
    print "Performing query `%s'" % query.get_description()

    enquire.set_query(query)
    matches = enquire.get_mset(0, 10)

    print "%i results found" % matches.get_matches_estimated()
    for match in matches:
        print "ID %i %i%% [%s]" % (match[xapian.MSET_DID], match[xapian.MSET_PERCENT], match[xapian.MSET_DOCUMENT].get_data())
	print match[xapian.MSET_DOCUMENT].get_value(NEWS_TITLE)

except Exception, e:
    print >> sys.stderr, "Exception: %s" % str(e)
    sys.exit(1)
Pojawił się wiersz print match[xapian.MSET_DOCUMENT].get_value(NEWS_TITLE), który wyświetli tytuł news. W prawdziwym kodzie przykładowe dane zastąpimy danymi z bazy danych.

Przykładowo dla Django, aplikacji newsów o takim modelu:
class News(models.Model):
	title = models.CharField(maxlength=255, verbose_name=_('Title'))
	slug = models.SlugField(maxlength=255, unique=True, prepopulate_from=("title", ), verbose_name=_('Slug'))
	text = models.TextField(verbose_name=_('Text'))
	text_more = models.TextField(verbose_name=_(' More Text'), blank=True)
	is_more = models.BooleanField(blank=True, default=False, verbose_name=_('Is More Text'))
	is_external = models.BooleanField(blank=True, default=False, verbose_name=_('Is External News'))
	category = models.ForeignKey(Category)
	date = models.DateField(auto_now = True)
	jakilinux_field = models.CharField(maxlength=255, verbose_name='Jakilinux', blank=True)
	wykop_field = models.CharField(maxlength=255, verbose_name='Wykop', blank=True)
	infoneo_field = models.CharField(maxlength=255, verbose_name='Infoneo', blank=True)
	digg_field = models.CharField(maxlength=255, verbose_name='Digg', blank=True)
	reddit_field = models.CharField(maxlength=255, verbose_name='Reddit', blank=True)
	class Meta:
		verbose_name = _('News')
		verbose_name_plural = _('News')
		db_table = 'rk_news' + str(settings.SITE_ID)
	class Admin:
		list_display = ('title', 'date')
		list_filter = ['date']
		search_fields = ['title', 'text']
		date_hierarchy = 'date'
	def get_absolute_url(self):
		return '/news/more/' + str(self.slug) + '/'
	def __str__(self):
		return self.title
Zindeksowanie wszystkich newsów wymaga zastosowania kodu postaci:
from os import environ
environ['DJANGO_SETTINGS_MODULE'] = 'settings'

from settings import *
from news.models import *

import xapian
import string

MAX_PROB_TERM_LENGTH = 64

def p_alnum(c):
    return (c in string.ascii_letters or c in string.digits)

def p_notalnum(c):
    return not p_alnum(c)

def p_notplusminus(c):
    return c != '+' and c != '-'

def find_p(string, start, predicate):
    while start<len(string) and not predicate(string[start]):
        start += 1
    return start

news = News.objects.all()
NEWS_ID = 0
NEWS_TITLE = 1
NEWS_DESC = 2
database = xapian.WritableDatabase('test/', xapian.DB_CREATE_OR_OPEN)
stemmer = xapian.Stem("english")
for new in news:
	para = new.text + str(new.text_more)
	doc = xapian.Document()
	doc.set_data(para)
	pos = 0
	i = 0
	while i < len(para):
		i = find_p(para, i, p_alnum)
		j = find_p(para, i, p_notalnum)
		k = find_p(para, j, p_notplusminus)
		if k == len(para) or not p_alnum(para[k]):
			j = k
		if (j - i) <= MAX_PROB_TERM_LENGTH and j > i:
			term = stemmer.stem_word(string.lower(para[i:j]))
			doc.add_posting(term, pos)
			pos += 1
		i = j
		doc.add_value(NEWS_ID, str(new.id))
		doc.add_value(NEWS_TITLE, new.title)
		doc.add_value(NEWS_DESC, new.text)
	database.add_document(doc)
Kod zapisujemy do pliku umieszczonego w katalogu projektu Django, tworzymy katalog "test" i wykonujemy skrypt. By przeszukać wystarczy zastosować plik z kodem wyszukiwarki. Dla mojej anglojęzycznej strony rkblog.rk.edu.pl szukając w newsach Django otrzymałem wynik w postaci:
[piotr@localhost biblioteka]$ python simplesearch.py django
Performing query `Xapian::Query(django)'
8 results found
How to Beat Rails - 100%
More crazy changes for Django 1.0 ? - 98%
Big Django project ;) - 87%
Django 0.96 released - 86%
polib - gettext translation manager - 71%
Diamanda 2006.12 Stable Released - 65%
More on Django 1.0 changes - 65%
Djangoish Gettext Translator - 57%


Pumping Up Your Applications with Xapian Full-Text Search - Bardziej zaawansowany przykład wykorzystujący XML-RPC i Twisted
RkBlog

Podstawy Pythona, 14 July 2008

Comment article
Comment article RkBlog main page Search RSS Contact